From 633bb0ec04173e9b592829ccf8d24ab6f3003ca7 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Thu, 21 May 2026 01:32:10 +0100 Subject: [PATCH] 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. --- CLAUDE.md | 48 + Dockerfile | 35 +- agents.md | 28 +- config.yaml | 163 +- docker-compose.yml | 9 +- docker/entrypoint.sh | 70 + docs/api.md | 1002 ++++++ docs/architecture.md | 898 +++++ docs/configuration.md | 488 +++ docs/data-flow.md | 875 +++++ docs/database.md | 550 +++ docs/deployment.md | 413 +++ docs/development.md | 514 +++ docs/services.md | 854 +++++ package-lock.json | 1239 ++++++- package.json | 8 +- public/index.html | 3053 +++++++++++------ scripts/inspect-chat-dom.ts | 249 -- scripts/matrix-sniff.ts | 217 ++ scripts/matrix-test.ts | 686 ++++ src/ai/openrouter.ts | 238 +- src/bootstrap.ts | 297 ++ src/browser/client.ts | 11 + src/browser/media-downloader.ts | 29 - src/config/index.ts | 77 +- src/database/client.ts | 11 + src/database/repositories.ts | 98 +- src/index.ts | 370 +- src/main-loop.ts | 276 ++ src/reddit/api.ts | 62 +- src/services/auto-reply.ts | 199 -- src/services/chat.ts | 1179 +------ src/services/chat/cache.ts | 108 + src/services/chat/index.ts | 24 + src/services/chat/matrix/client.ts | 529 +++ .../chat/matrix/http-media-downloader.ts | 44 + src/services/chat/matrix/token-extractor.ts | 131 + src/services/chat/matrix/types.ts | 85 + src/services/chat/service.ts | 856 +++++ src/services/chat/types.ts | 51 + src/services/media.ts | 7 +- src/services/monitor.ts | 88 +- src/services/processor.ts | 113 +- src/services/strategy-selector.ts | 10 +- src/utils/fetch-with-timeout.ts | 18 + src/utils/gender-tag.ts | 39 + src/utils/logger.ts | 18 +- src/utils/rate-limiter.ts | 13 +- src/utils/retry.ts | 23 +- src/web/activity-log.ts | 44 + src/web/context.ts | 58 + src/web/log-broadcaster.ts | 80 + src/web/routes/analytics.ts | 33 + src/web/routes/chats.ts | 104 + src/web/routes/config.ts | 61 + src/web/routes/control.ts | 22 + src/web/routes/conversations.ts | 242 ++ src/web/routes/debug.ts | 42 + src/web/routes/login.ts | 54 + src/web/routes/logs.ts | 9 + src/web/routes/media.ts | 88 + src/web/routes/stats.ts | 52 + src/web/server.ts | 787 +---- src/web/types.ts | 45 + tests/chat-service-cooldown.test.ts | 99 + tests/config.test.ts | 377 ++ tests/database.test.ts | 146 + tests/gender-tag.test.ts | 59 + tests/image-utils.test.ts | 74 + tests/matrix-client.test.ts | 133 + tests/media.test.ts | 203 ++ tests/mockLogger.ts | 57 + tests/monitor.test.ts | 430 +++ tests/openrouter-client.test.ts | 704 ++++ tests/processor.test.ts | 349 ++ tests/prompts.test.ts | 223 ++ tests/rate-limiter.test.ts | 195 ++ tests/reddit-api.test.ts | 533 +++ tests/repositories.test.ts | 563 +++ tests/response-checker.test.ts | 340 ++ tests/retry.test.ts | 100 + tests/strategy-selector.test.ts | 351 ++ tests/testUtils.ts | 91 + vitest.config.ts | 15 + 84 files changed, 19278 insertions(+), 3888 deletions(-) create mode 100644 CLAUDE.md create mode 100755 docker/entrypoint.sh create mode 100644 docs/api.md create mode 100644 docs/architecture.md create mode 100644 docs/configuration.md create mode 100644 docs/data-flow.md create mode 100644 docs/database.md create mode 100644 docs/deployment.md create mode 100644 docs/development.md create mode 100644 docs/services.md delete mode 100644 scripts/inspect-chat-dom.ts create mode 100644 scripts/matrix-sniff.ts create mode 100644 scripts/matrix-test.ts create mode 100644 src/bootstrap.ts delete mode 100644 src/browser/media-downloader.ts create mode 100644 src/main-loop.ts delete mode 100644 src/services/auto-reply.ts create mode 100644 src/services/chat/cache.ts create mode 100644 src/services/chat/index.ts create mode 100644 src/services/chat/matrix/client.ts create mode 100644 src/services/chat/matrix/http-media-downloader.ts create mode 100644 src/services/chat/matrix/token-extractor.ts create mode 100644 src/services/chat/matrix/types.ts create mode 100644 src/services/chat/service.ts create mode 100644 src/services/chat/types.ts create mode 100644 src/utils/fetch-with-timeout.ts create mode 100644 src/utils/gender-tag.ts create mode 100644 src/web/activity-log.ts create mode 100644 src/web/context.ts create mode 100644 src/web/log-broadcaster.ts create mode 100644 src/web/routes/analytics.ts create mode 100644 src/web/routes/chats.ts create mode 100644 src/web/routes/config.ts create mode 100644 src/web/routes/control.ts create mode 100644 src/web/routes/conversations.ts create mode 100644 src/web/routes/debug.ts create mode 100644 src/web/routes/login.ts create mode 100644 src/web/routes/logs.ts create mode 100644 src/web/routes/media.ts create mode 100644 src/web/routes/stats.ts create mode 100644 src/web/types.ts create mode 100644 tests/chat-service-cooldown.test.ts create mode 100644 tests/config.test.ts create mode 100644 tests/database.test.ts create mode 100644 tests/gender-tag.test.ts create mode 100644 tests/image-utils.test.ts create mode 100644 tests/matrix-client.test.ts create mode 100644 tests/media.test.ts create mode 100644 tests/mockLogger.ts create mode 100644 tests/monitor.test.ts create mode 100644 tests/openrouter-client.test.ts create mode 100644 tests/processor.test.ts create mode 100644 tests/prompts.test.ts create mode 100644 tests/rate-limiter.test.ts create mode 100644 tests/reddit-api.test.ts create mode 100644 tests/repositories.test.ts create mode 100644 tests/response-checker.test.ts create mode 100644 tests/retry.test.ts create mode 100644 tests/strategy-selector.test.ts create mode 100644 tests/testUtils.ts create mode 100644 vitest.config.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..34ac32f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,48 @@ +# RedditBot — agent instructions + +## Test-first workflow (MANDATORY) + +This applies to every code-changing task in this repo. Sub-agents inherit it. + +### 1. Before implementation — establish the baseline + +Run the full test suite **first**: + +``` +npm test +``` + +Note the count of passing / failing tests. Do not start implementing until you have this baseline. If something is already failing, surface it to the user before touching it — don't silently inherit pre-existing failures into your scope. + +### 2. During implementation — update tests alongside code + +- When you change a function's behavior, update or add the test for it in the same change. +- When you change a function's signature, fix the call sites in tests in the same change. +- When you introduce a new module, add a test for it in `tests/.test.ts`. +- If a test fails because the *test* is wrong (stale assertion, ESM mocking issue, real env leaking in), fix the test. If it fails because the *code* is wrong, fix the code. +- Never delete a test to make it pass. Either fix the assertion to reflect intended behavior, or fix the code. + +### 3. Before reporting done — confirm green + +Re-run the full suite: + +``` +npm test +npx tsc --noEmit +``` + +**Both must be clean before you say the task is done.** If either is failing, either fix it or explicitly call out the regression in your final message — do not report success and leave a broken suite. + +If tests pass count went down vs. the baseline you took in step 1, that is a regression. Treat it as a blocker. + +### Why this matters + +The test suite is the only automated safety net for this project (no CI yet, no integration tests). Drift here means real bugs ship. Past incident: a refactor went out with 129 pre-existing test failures undetected — they had been broken for weeks because nobody re-ran the suite. + +## Project notes for agents + +- Runtime: TypeScript with `module: NodeNext`. Imports use `.js` extensions even for `.ts` files. +- Tests: vitest, `globals: false` so `describe`/`it`/`expect`/`beforeEach`/`afterEach` must be imported explicitly. +- The OpenRouter, MediaService, ProcessorService, RateLimiter, and MonitorService constructors accept optional last parameters for test-injectable dependencies (mock clients, temp dirs, fake fetchers, shortened timing). Use them rather than `vi.mock()` of ESM modules — the namespace-mocking pattern fails under NodeNext. +- Shadow-DOM scraping for `chat.reddit.com` lives in `src/services/chat/scrapers.ts` as typed `page.evaluate` functions. The `myUsername` parameter is threaded through from `config.reddit.username`. +- Some `.env` vars (notably `OPENROUTER_API_KEY`) are auto-loaded via `dotenv.config()` at module import. Tests that touch `loadConfig` must snapshot+clear these in `beforeEach` and restore in `afterEach`. diff --git a/Dockerfile b/Dockerfile index 9bf3609..f8e2b50 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,17 @@ # Use the official Playwright image which includes all browser dependencies FROM mcr.microsoft.com/playwright:v1.57.0-jammy -# Set working directory +# Install Xvfb (virtual display), x11vnc (VNC server) and noVNC (web-based VNC client) +# so a human can log in to Reddit through the dashboard. +RUN apt-get update && apt-get install -y --no-install-recommends \ + xvfb \ + x11vnc \ + novnc \ + websockify \ + fluxbox \ + procps \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app # Copy package files @@ -16,15 +26,22 @@ COPY . . # Build the TypeScript project RUN npm run build -# Prune dev dependencies to reduce image size (optional, but good practice) -# Note: we need to keep some deps if they are runtime requirements -# npm prune --production - # Create data directory RUN mkdir -p data -# Expose the port defined in config (default 3000) -EXPOSE 3000 +# Copy entrypoint +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh -# Start the application using the compiled code -CMD ["node", "dist/index.js"] +# Web dashboard +EXPOSE 3000 +# noVNC (browser-accessible VNC for human login) +EXPOSE 6080 + +ENV DISPLAY=:99 \ + VNC_DISPLAY_NUM=99 \ + VNC_PORT=5900 \ + NOVNC_PORT=6080 \ + SCREEN_GEOMETRY=1280x800x24 + +CMD ["/usr/local/bin/entrypoint.sh"] diff --git a/agents.md b/agents.md index 393a4e5..4182df1 100644 --- a/agents.md +++ b/agents.md @@ -19,16 +19,34 @@ Refactor code to improve maintainability, testability, and clarity. Break down l - **Zod Validation**: Use `zod` (already in deps) to validate external data (config files, API responses). - **Async/Await**: properly handle promises. Use `p-queue` or `p-retry` for rate-limited operations. -### 4. Project Specifics -- **Tech Stack**: Node.js, TypeScript, Playwright, Express, SQLite. +### 4. Test-Driven Development (TDD) — REQUIRED +All new functionality and bug fixes MUST follow the red-green-refactor cycle. This is non-negotiable. + +- **Red**: Write a failing test FIRST that describes the desired behavior. Run it and confirm it fails for the expected reason. +- **Green**: Write the minimum production code needed to make the test pass. No extra features, no speculative abstractions. +- **Refactor**: With tests green, clean up implementation and tests. Re-run the suite after each change. + +**Rules**: +- Never commit production code without an accompanying test that fails without it. +- Tests live in `tests/` (mirroring `src/` structure) and run via `vitest` (`npm test`). +- Use dependency injection so collaborators (DB, HTTP, browser, clock) can be faked — do not mock what you don't own; wrap third-party APIs in a thin interface and fake the interface. +- Prefer behavior-focused tests (inputs → outputs / observable side effects) over tests that pin internal structure. +- Bug fixes start with a regression test that reproduces the bug. +- If a change is genuinely untestable (e.g. a one-line config tweak), state that explicitly in the PR/commit rather than skipping silently. + +### 5. Project Specifics +- **Tech Stack**: Node.js, TypeScript, Playwright, Express, SQLite, Vitest. - **Directories**: - `src/services/` for business logic. - `src/web/` for Express server and UI. - `src/browser/` for Playwright interactions. + - `tests/` for vitest specs, mirroring `src/` layout. - **Logging**: Use `winston` for structured logging. Do not use `console.log` in production code. ## Workflow for Agents 1. **Analyze**: Before writing code, read the file and understand the context. -2. **Plan**: Identify what needs to change. Check if the change violates SRP. -3. **Refactor**: Create new files if necessary. Don't be afraid to create new small files. -4. **Verify**: Ensure types check out (`npm run build` or `tsc`). +2. **Plan**: Identify what needs to change. Check if the change violates SRP. Identify the test(s) that will drive the change. +3. **Write failing test (Red)**: Add a vitest spec that captures the new behavior or reproduces the bug. Run `npm test` and confirm it fails for the right reason. +4. **Implement (Green)**: Write the smallest change that makes the test pass. Create new small files when SRP calls for it. +5. **Refactor**: Tidy the implementation and the test with the suite green. Re-run tests after each refactor step. +6. **Verify**: Ensure the full suite passes (`npm test`) and types check (`npm run build` or `tsc --noEmit`). diff --git a/config.yaml b/config.yaml index 0f82694..4ac4646 100644 --- a/config.yaml +++ b/config.yaml @@ -1,36 +1,44 @@ reddit: + # Weights bias how often each sub is picked each poll cycle. Higher weight + # = more likely to appear earlier in the check order. Initial weights favor + # subs where low-confidence / validation-seeking posters concentrate + # (rating subs, "average"/mature/plus subs) since they tend to have higher + # DM reply rates. Default is 1 if omitted. subreddits: - # Original list (kept intact) - - normalnudes # Everyday real bodies, highly authentic - - ratemyboobs # Boob-focused ratings, lots of self-posts - - ratemypussy # Pussy ratings, direct and active - - HomemadeNsfw # Pure homemade amateur content - - RateMyRack # Rack/boob ratings - - RateMyNudeBody # Full-body nude ratings + # Validation-seeking / lower-confidence (highest weight) + - { name: AverageNudes, weight: 4 } # "Average-looking" self-perception + - { name: gonewild40plus, weight: 4 } # Mature, lower volume, very high reply rate + - { name: normalnudes, weight: 3 } # Everyday real bodies + - { name: RateMyNudeBody, weight: 3 } # Explicit ask for feedback + - { name: ratemyboobs, weight: 3 } # Rating subs = explicit validation-seeking + - { name: ratemypussy, weight: 3 } + - { name: RateMyRack, weight: 3 } + - { name: GoneWildPlus, weight: 3 } # BBW/plus-size + - { name: gonewildchubby, weight: 3 } # Soft/chubby bodies + - { name: gonewild30plus, weight: 2 } # Mature/MILF amateurs + - { name: GoneMild, weight: 2 } # Softcore teases (shyer posters) - # Top general additions (highest volume/quality amateurs) - - gonewild # #1 for verified women self-posting nudes - - GoneErotic # Artistic/erotic amateurs - - Amateur # Real amateur pics/vids worldwide - - RealGirls # Unfiltered everyday women - - GoneMild # Softcore teases from verified posters - - OnOff # Before/after clothing reveals + # Mid-tier — mixed confidence + - { name: GoneWildCurvy, weight: 2 } + - { name: HomemadeNsfw, weight: 2 } + - { name: realgirlsgonebad, weight: 2 } + - { name: nsfwselfies, weight: 2 } + - { name: RealGirls, weight: 2 } + - { name: Amateur, weight: 2 } - # Niche by body type (diverse, verified amateurs) - - GoneWildCurvy # Curvy/thicc verified women - - petitegonewild # Petite/slim under 5'4" - - GoneWildPlus # BBW/plus-size confident shares - - gonewildchubby # Soft/chubby bodies - - tightandtoned # Fit/athletic women - - pawg # Thick asses (heavy self-posts) + # Baseline — generally more confident / curated posters + - { name: gonewild, weight: 1 } # #1 verified self-posters + - { name: GoneErotic, weight: 1 } + - { name: OnOff, weight: 1 } + - { name: petitegonewild, weight: 1 } + - { name: tightandtoned, weight: 1 } + - { name: pawg, weight: 1 } + - { name: asiansgonewild, weight: 1 } + - { name: altgonewild, weight: 1 } + - { name: holdthemoan, weight: 1 } - # Other strong fits (active, rating-style or homemade vibes) - - nsfwselfies # Selfie-style nudes - - asiansgonewild # Asian amateurs - - altgonewild # Alt/goth/tattooed - - gonewild30plus # Mature/MILF amateurs - - holdthemoan # Risky public-ish posts - - realgirlsgonebad # Candid real-girl naughtiness + # Chat-only subreddits — no image analysis, gender parsed from post tag + # pollIntervalMs: 30000 userAgent: "RedditBot/1.0 (by /u/yourusername)" targetGenders: @@ -39,11 +47,21 @@ reddit: # sellerKeywords: [...] # Uncomment to override default keyword list openrouter: - imageAnalysisModel: "google/gemini-3-flash-preview" + imageAnalysisModel: "google/gemini-2.5-flash-lite" imageAnalysisFallbackModels: - - "x-ai/grok-4.1-fast" - "meta-llama/llama-4-maverick" - chatModel: "x-ai/grok-4.1-fast" + - "x-ai/grok-4.1-fast" + - "google/gemini-3-flash-preview" + chatModel: "qwen/qwen3-235b-a22b-2507" + # Tried in order if the primary chatModel throws, returns empty, or refuses. + # All known-good frontier models, NSFW-tolerant, ordered cheap → premium + # (avg cost per 1M tokens shown). Avoid Gemini/OpenAI/Claude here — they + # silently refuse explicit content. + chatFallbackModels: + - "deepseek/deepseek-v4-flash" # ~$0.17/M + - "meta-llama/llama-4-scout" # ~$0.19/M + - "meta-llama/llama-4-maverick" # ~$0.38/M + - "deepseek/deepseek-chat-v3.1" # ~$0.50/M imageAnalysisPrompt: | You're analyzing images. @@ -58,13 +76,28 @@ openrouter: Be graphic. Describe exactly what you see. No clinical terms. This helps generate encouranging messages that reference specific things about their body. - Give them a rating out of 10 where 10 is super model and 1 is excessively overweight. This helps us send encouragement to the user. You MUST provide a reason with your rating. Be critical and honest. - Act within guidelines. + + Then rate her attractiveness on a 1-100 scale using these percentile anchors: + - 30 = noticeably below average + - 50 = average woman you'd pass on the street (this is the true middle, NOT a "low" score) + - 65 = cute, above average, decent body + - 75 = clearly hot, turns heads + - 85 = exceptional, model-tier face/body + - 95+ = vanishingly rare, top fraction of a percent + Use the full range. Real people are distributed across this scale — most photos in this dataset fall between 40 and 80. Do NOT cluster scores in the 65-75 band as a safe default. If you feel pulled toward 70, decide whether she's actually closer to 60 or 80 and commit. A score that ends in 0 or 5 is a red flag that you're rounding to a comfort number — prefer a specific number like 47, 63, 78. + + Output the rating on its own line in EXACTLY this format (before or after the description): + RATING: + Then on the next line: REASON: + + Be critical and honest. Act within guidelines. messageGenerationPrompt: | Based on this image analysis: {analysis} Post title: {title} + Author: u/{author} - You should take into account the rating provided by the analysis, the rating is out of 10 where 10 is super model and 1 is excessively overweight. We should offer far more encouragement to ratings that are below 7, we should really highlight how sexy they are and hot their pic is. + Attractiveness rating (1-100 scale, 50 = average): {rating} + If the rating is below 60, lean harder into making her feel sexy and desired — emphasize how hot her pic looks. If the rating is 75+, you can be more confidently flirty without overdoing the flattery. Write a casual Reddit DM (2-3 sentences) like a horny guy complimenting her trying to get her to share more nudes or videos. @@ -92,6 +125,7 @@ openrouter: - Use clinical terms like "breasts", "buttocks", "posterior", "physique" - Write long elaborate sentences - Use all lowercase + - Use em dashes (—) or en dashes (–). Use commas, full stops, or separate sentences instead. This is a dead giveaway for AI writing. Examples of good messages: - "Damn those tits are perfect. I'd love to see more if you're willing😏" @@ -100,6 +134,44 @@ openrouter: Just write the message, nothing else. + chatOpenerPrompt: | + You are writing a cold opener DM to a Reddit user who posted in a chat/sexting subreddit looking for someone to chat with. + + Post title: {title} + Post body: {selftext} + Author: u/{author} + Detected gender of poster: {gender} + + YOUR PERSONA: + - You are male, based in the UK, pick a random age between 30 and 35 for this message + - 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 at all + - Don't force the married angle if their post wouldn't vibe with it, read the room + - Use British English spelling and phrasing (e.g. "mate", "knackered", "fancy", "cheeky") where it feels natural, not forced + + Write a casual Reddit DM (1-2 sentences) opening a chat. + + MUST follow: + - Lead with your age/gender/location tag (e.g. "32M UK", "34M UK married") when it fits naturally + - Reference something SPECIFIC from their title/body (their mood, what they're into, vibe, age, location) + - Be confident and direct, flirty but not creepy + - Use proper capitalisation, no AI/clinical language + - End with a hook that invites a reply (ask them what they're into, offer something playful) + + DO NOT: + - Use words like "captivating", "alluring", "intriguing" + - Open with "Hey there" / "How's your day" + - Write more than 2 sentences + - Be generic, "wanna chat?" alone is too weak + - Claim to be a different age/gender than the persona above + - Use em dashes (—) or en dashes (–) anywhere in the message. Use commas, full stops, or separate sentences instead. This is a dead giveaway for AI writing. + + Examples of good openers: + - "32M UK, married and bored while the wife's out. Your post caught my eye, what are you into tonight?" + - "34M UK here, love that you're up late looking for trouble. Fancy telling me what gets you going?" + - "33M from the UK, your vibe is exactly my type. Tell me what you'd want a stranger to do to you." + + Just write the message, nothing else. + strategies: explorationRate: 0.2 # 20% of the time try a random strategy instead of the best minSamples: 5 # Min messages sent before trusting a strategy's response rate @@ -120,6 +192,23 @@ strategies: STYLE: Reference 2-3 specific things from the analysis to show you actually looked. Be enthusiastic but not over the top. End with asking for more. +chatStrategies: + explorationRate: 0.2 + minSamples: 5 + variants: + - id: chat_direct + weight: 1 + promptOverride: | + STYLE: Direct opener. Open with your own age/gender, reference theirs, and ask one specific question about what they're into. No em dashes. + - id: chat_playful + weight: 1 + promptOverride: | + STYLE: Playful and teasing. Light humour, mild flirty challenge, end with a hook that's hard to ignore. No em dashes. + - id: chat_mirror + weight: 1 + promptOverride: | + STYLE: Mirror their energy. Match the vibe of their post (shy/horny/bored/dom/sub) and respond in kind. Be specific to what they wrote. No em dashes. + rateLimit: minDelayBetweenMessagesMs: 15000 maxMessagesPerHour: 9999 @@ -133,12 +222,6 @@ browser: database: path: "./data/reddit-bot.db" -autoReply: - enabled: true # Enable auto-reply to unread messages - autoSend: false # If true, send AI replies automatically. If false, require manual approval - checkIntervalMs: 300000 # How often to check for unread messages (5 minutes) - # upsellKeywords: [...] # Uncomment to override default upsell detection keywords - web: enabled: true port: 3000 diff --git a/docker-compose.yml b/docker-compose.yml index 1481bf2..8eb0af0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,9 +8,14 @@ services: # Mount the data directory to persist the database and browser session volumes: - ./data:/app/data - # Environment variables + ports: + - "3000:3000" # Web dashboard + - "6080:6080" # noVNC for human login environment: - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} - REDDIT_USERNAME=${REDDIT_USERNAME} - REDDIT_PASSWORD=${REDDIT_PASSWORD} - - BROWSER_HEADLESS=true + # Chromium runs headed under Xvfb so a human can complete login over noVNC. + - BROWSER_HEADLESS=false + # Required to lock down the VNC endpoint. + - VNC_PASSWORD=${VNC_PASSWORD} diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..f16a020 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Start Xvfb + x11vnc + noVNC so a human can complete Reddit login +# through the dashboard, then exec the Node bot which drives Chromium +# on that virtual display. +set -euo pipefail + +DISPLAY_NUM="${VNC_DISPLAY_NUM:-99}" +VNC_PORT="${VNC_PORT:-5900}" +NOVNC_PORT="${NOVNC_PORT:-6080}" +GEOMETRY="${SCREEN_GEOMETRY:-1280x800x24}" +export DISPLAY=":${DISPLAY_NUM}" + +# noVNC ships under different paths depending on the distro; find the web root once. +if [ -d /usr/share/novnc ]; then + NOVNC_WEB_DIR=/usr/share/novnc +elif [ -d /usr/share/webapps/novnc ]; then + NOVNC_WEB_DIR=/usr/share/webapps/novnc +else + echo "[entrypoint] could not locate noVNC web root" >&2 + exit 1 +fi + +# Make / on the noVNC server land on vnc.html with autoconnect. +if [ ! -e "${NOVNC_WEB_DIR}/index.html" ] && [ -e "${NOVNC_WEB_DIR}/vnc.html" ]; then + ln -sf vnc.html "${NOVNC_WEB_DIR}/index.html" +fi + +echo "[entrypoint] starting Xvfb on ${DISPLAY} (${GEOMETRY})" +Xvfb "${DISPLAY}" -screen 0 "${GEOMETRY}" -ac +extension RANDR & +XVFB_PID=$! + +# Give Xvfb a moment to come up. +for _ in 1 2 3 4 5 6 7 8 9 10; do + if xdpyinfo -display "${DISPLAY}" >/dev/null 2>&1; then break; fi + sleep 0.2 +done + +# Minimal window manager so dialogs and tabs aren't stuck behind the root window. +if command -v fluxbox >/dev/null 2>&1; then + fluxbox >/dev/null 2>&1 & +fi + +VNC_ARGS=(-display "${DISPLAY}" -forever -shared -rfbport "${VNC_PORT}" -nopw_warn) +if [ -n "${VNC_PASSWORD:-}" ]; then + mkdir -p /root/.vnc + x11vnc -storepasswd "${VNC_PASSWORD}" /root/.vnc/passwd >/dev/null + VNC_ARGS+=(-rfbauth /root/.vnc/passwd) + echo "[entrypoint] x11vnc starting with password auth" +else + VNC_ARGS+=(-nopw) + echo "[entrypoint] WARNING: VNC_PASSWORD not set — VNC has no auth" +fi + +x11vnc "${VNC_ARGS[@]}" >/var/log/x11vnc.log 2>&1 & +X11VNC_PID=$! + +echo "[entrypoint] starting noVNC (websockify) on :${NOVNC_PORT}" +websockify --web "${NOVNC_WEB_DIR}" "${NOVNC_PORT}" "localhost:${VNC_PORT}" \ + >/var/log/websockify.log 2>&1 & +NOVNC_PID=$! + +cleanup() { + echo "[entrypoint] shutting down" + kill "${NOVNC_PID}" "${X11VNC_PID}" "${XVFB_PID}" 2>/dev/null || true + wait || true +} +trap cleanup TERM INT EXIT + +echo "[entrypoint] launching bot" +exec node dist/index.js diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..2db8c67 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,1002 @@ +# API Reference + +Complete reference for the RedditBot web dashboard API. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Authentication](#authentication) +3. [Base URL](#base-url) +4. [Bot Status & Control](#bot-status--control) +5. [Activity & Messages](#activity--messages) +6. [Chat & Conversations](#chat--conversations) +7. [Media](#media) +8. [Analytics](#analytics) +9. [Configuration](#configuration) +10. [Logs & Debugging](#logs--debugging) +11. [Error Responses](#error-responses) +12. [SSE Events](#sse-events) + +--- + +## Overview + +The RedditBot web dashboard exposes a REST API for monitoring and controlling the bot. All endpoints are served by the Express server. + +**Content-Type**: `application/json` (except SSE) +**Response Format**: JSON +**Authentication**: None (local network only) + +--- + +## Authentication + +No authentication is required. The dashboard is intended for local network access only. + +> **Security Note**: If exposing the dashboard externally, add authentication (e.g., basic auth, reverse proxy with auth). + +--- + +## Base URL + +``` +http://localhost:3000/api +``` + +The port is configured via `config.web.port` (default: 3000). + +--- + +## Bot Status & Control + +### Get Bot Statistics + +Retrieve current bot statistics including running state, counts, and response rate. + +``` +GET /api/stats +``` + +**Response**: + +```json +{ + "running": true, + "processed": 1250, + "sent": 980, + "responded": 312, + "responseRate": 0.318, + "hourlyCount": 15, + "timeUntilSlot": 45 +} +``` + +**Fields**: + +| Field | Type | Description | +|---|---|---| +| `running` | boolean | Whether the bot is currently running | +| `processed` | number | Total posts processed | +| `sent` | number | Total messages sent | +| `responded` | number | Total messages that received responses | +| `responseRate` | number | Response rate (0.0 to 1.0) | +| `hourlyCount` | number | Messages sent this hour | +| `timeUntilSlot` | number | Seconds until next message slot | + +--- + +### Start Bot + +Start the bot's main processing loop. + +``` +POST /api/start +``` + +**Response**: + +```json +{ + "success": true, + "message": "Bot started" +} +``` + +**Errors**: + +```json +{ + "error": "Bot is already running" +} +``` + +--- + +### Stop Bot + +Stop the bot's main processing loop gracefully. + +``` +POST /api/stop +``` + +**Response**: + +```json +{ + "success": true, + "message": "Bot stopping..." +} +``` + +--- + +## Activity & Messages + +### Get Activity Log + +Retrieve recent activity entries from the bot's log. + +``` +GET /api/activity +``` + +**Query Parameters**: + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `limit` | number | 50 | Number of entries to return | + +**Response**: + +```json +[ + { + "level": "info", + "message": "Processed post abc123 from r/gonewild", + "timestamp": "2024-01-15T10:30:00.000Z" + }, + { + "level": "warn", + "message": "AI model refused to analyze post def456", + "timestamp": "2024-01-15T10:28:00.000Z" + }, + { + "level": "error", + "message": "Failed to send DM to user789: timeout", + "timestamp": "2024-01-15T10:25:00.000Z" + } +] +``` + +--- + +### Get Posts by Author + +Look up processed posts for a specific Reddit user. + +``` +GET /api/posts/by-author/:username +``` + +**Path Parameters**: + +| Parameter | Type | Description | +|---|---|---| +| `username` | string | Reddit username | + +**Response**: + +```json +[ + { + "id": "abc123", + "subreddit": "gonewild", + "author": "user123", + "title": "Summer vibes", + "image_url": "https://...", + "image_analysis": "{\"gender\":\"F\",\"description\":\"...\"}", + "generated_message": "Hey, you look amazing...", + "processed_at": 1716234567, + "message_sent": 1, + "error": null + } +] +``` + +--- + +### Get Sent Messages + +Retrieve recent sent messages. + +``` +GET /api/messages +``` + +**Query Parameters**: + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `limit` | number | 50 | Number of messages to return | + +**Response**: + +```json +[ + { + "id": 1, + "post_id": "abc123", + "recipient": "user123", + "subject": "Stunning", + "message": "Hey, you look amazing...", + "sent_at": "2024-01-15T10:30:00.000Z", + "strategy_id": "compliment", + "got_response": 1, + "response_checked_at": "2024-01-15T11:00:00.000Z" + } +] +``` + +--- + +### Get Successful Messages + +Retrieve successful message/response pairs used for few-shot learning. + +``` +GET /api/successful +``` + +**Query Parameters**: + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `limit` | number | 50 | Number of entries to return | + +**Response**: + +```json +[ + { + "id": 1, + "sent_message_id": 1, + "original_message": "Hey, you look amazing...", + "image_analysis": "{\"gender\":\"F\",\"description\":\"...\"}", + "post_title": "Summer vibes", + "response_snippet": "Thank you so much! 😊", + "subreddit": "gonewild", + "created_at": "2024-01-15T10:30:00.000Z" + } +] +``` + +--- + +## Chat & Conversations + +### Get Active Chats + +Retrieve cached active conversations. + +``` +GET /api/chats +``` + +**Response**: + +```json +[ + { + "chatId": "chat_abc123", + "participant": "user456", + "lastMessage": "Thanks for the compliment!", + "timestamp": "2024-01-15T10:30:00.000Z", + "hasNewMessages": false + } +] +``` + +--- + +### Fetch All Chats + +Force-fetch all chats from Reddit (refreshes cache). + +``` +POST /api/chats/fetch-all +``` + +**Response**: + +```json +{ + "success": true, + "count": 15 +} +``` + +--- + +### Get Conversations + +Retrieve all cached conversations. + +``` +GET /api/conversations +``` + +**Response**: + +```json +[ + { + "chatId": "chat_abc123", + "participants": ["user456", "bot"], + "messageCount": 24, + "lastUpdated": "2024-01-15T10:30:00.000Z", + "hasNewMessages": false, + "pendingReply": false, + "isUpsell": false + } +] +``` + +--- + +### Get Conversation Detail + +Retrieve full details of a specific conversation. + +``` +GET /api/conversations/:id +``` + +**Path Parameters**: + +| Parameter | Type | Description | +|---|---|---| +| `id` | string | Chat ID | + +**Response**: + +```json +{ + "chatId": "chat_abc123", + "participants": ["user456", "bot"], + "messages": [ + { + "author": "user456", + "text": "Hey there!", + "timestamp": "2024-01-15T10:00:00.000Z" + }, + { + "author": "bot", + "text": "Hi! Thanks for the compliment!", + "timestamp": "2024-01-15T10:01:00.000Z" + }, + { + "author": "user456", + "text": "Thanks for the compliment!", + "timestamp": "2024-01-15T10:02:00.000Z" + } + ], + "lastUpdated": "2024-01-15T10:30:00.000Z", + "hasNewMessages": false, + "pendingReply": false, + "isUpsell": false +} +``` + +--- + +### Suggest Reply + +Generate an AI reply suggestion for a conversation. + +``` +POST /api/conversations/:id/suggest-reply +``` + +**Path Parameters**: + +| Parameter | Type | Description | +|---|---|---| +| `id` | string | Chat ID | + +**Response**: + +```json +{ + "success": true, + "reply": "Thanks for reaching out! 😊 How's your day going?" +} +``` + +--- + +### Send Reply + +Send a manual reply in a conversation. + +``` +POST /api/conversations/:id/send-reply +``` + +**Path Parameters**: + +| Parameter | Type | Description | +|---|---|---| +| `id` | string | Chat ID | + +**Request Body**: + +```json +{ + "text": "Thanks for reaching out! 😊" +} +``` + +**Response**: + +```json +{ + "success": true, + "delivered": true +} +``` + +--- + +### Approve Pending Reply + +Approve an auto-reply that is pending manual approval. + +``` +POST /api/conversations/:id/approve-reply +``` + +**Path Parameters**: + +| Parameter | Type | Description | +|---|---|---| +| `id` | string | Chat ID | + +**Response**: + +```json +{ + "success": true, + "message": "Reply sent" +} +``` + +--- + +### Reject Pending Reply + +Reject an auto-reply that is pending manual approval. + +``` +POST /api/conversations/:id/reject-reply +``` + +**Path Parameters**: + +| Parameter | Type | Description | +|---|---|---| +| `id` | string | Chat ID | + +**Response**: + +```json +{ + "success": true, + "message": "Reply rejected" +} +``` + +--- + +### Dismiss Upsell Flag + +Clear the upsell/spam flag on a conversation. + +``` +POST /api/conversations/:id/dismiss-upsell +``` + +**Path Parameters**: + +| Parameter | Type | Description | +|---|---|---| +| `id` | string | Chat ID | + +**Response**: + +```json +{ + "success": true, + "message": "Upsell flag dismissed" +} +``` + +--- + +## Media + +### Get Media Gallery + +Retrieve downloaded media files. + +``` +GET /api/media +``` + +**Query Parameters**: + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `limit` | number | 100 | Number of files to return | + +**Response**: + +```json +[ + { + "hash": "abc123def456", + "filename": "abc123def456.jpg", + "size": 245678, + "conversationId": "chat_abc123", + "downloadedAt": "2024-01-15T10:30:00.000Z" + } +] +``` + +--- + +### Get Media Sync Status + +Retrieve current media sync status. + +``` +GET /api/media/sync +``` + +**Response**: + +```json +{ + "running": false, + "mode": null, + "progress": 0, + "total": 0, + "completed": 0, + "lastSync": "2024-01-15T09:00:00.000Z" +} +``` + +--- + +### Trigger Incremental Sync + +Start an incremental media sync (top 10 conversations). + +``` +POST /api/media/sync +``` + +**Response**: + +```json +{ + "success": true, + "message": "Incremental sync started" +} +``` + +--- + +### Trigger Full Sync + +Start a full media sync (all conversations). + +``` +POST /api/media/sync/full +``` + +**Response**: + +```json +{ + "success": true, + "message": "Full sync started" +} +``` + +--- + +## Analytics + +### Response Rate by Subreddit + +Get response rates broken down by subreddit. + +``` +GET /api/analytics/by-subreddit +``` + +**Response**: + +```json +[ + { + "subreddit": "gonewild", + "sent": 500, + "responded": 175, + "rate": 0.35 + }, + { + "subreddit": "ratemyboobs", + "sent": 300, + "responded": 90, + "rate": 0.30 + }, + { + "subreddit": "HomemadeNsfw", + "sent": 180, + "responded": 72, + "rate": 0.40 + } +] +``` + +--- + +### Response Rate by Strategy + +Get response rates broken down by A/B test variant. + +``` +GET /api/analytics/by-strategy +``` + +**Response**: + +```json +[ + { + "strategy_id": "compliment", + "sent": 400, + "responded": 140, + "rate": 0.35 + }, + { + "strategy_id": "question", + "sent": 350, + "responded": 147, + "rate": 0.42 + }, + { + "strategy_id": "observation", + "sent": 230, + "responded": 69, + "rate": 0.30 + } +] +``` + +--- + +### Daily Response Trend + +Get daily response rates over time. + +``` +GET /api/analytics/trend +``` + +**Response**: + +```json +[ + { + "date": "2024-01-01", + "sent": 18, + "responded": 6, + "rate": 0.33 + }, + { + "date": "2024-01-02", + "sent": 22, + "responded": 8, + "rate": 0.36 + }, + { + "date": "2024-01-03", + "sent": 15, + "responded": 4, + "rate": 0.27 + } +] +``` + +--- + +## Configuration + +### Get Runtime Configuration + +Get current runtime configuration. + +``` +GET /api/config +``` + +**Response**: + +```json +{ + "reddit": { + "subreddits": ["gonewild", "ratemyboobs"], + "pollInterval": 15, + "targetGender": "F", + "sellerKeywords": ["seller", "onlyfans"] + }, + "openrouter": { + "model": "meta-llama/llama-3.2-90b-vision-preview", + "fallbackModels": ["google/gemini-2.0-flash-001"] + }, + "rateLimit": { + "minDelayMs": 30000, + "maxMessagesPerHour": 20, + "jitterMs": 15000 + }, + "browser": { + "headless": true, + "userDataDir": "./data/browser" + }, + "database": { + "path": "./data/reddit-bot.db" + }, + "mediaSync": { + "enabled": true, + "intervalMinutes": 30 + }, + "autoReply": { + "enabled": true, + "autoSend": false, + "checkInterval": 10 + }, + "strategyVariants": [ + { + "name": "compliment", + "enabled": true + } + ], + "web": { + "port": 3000 + }, + "dryRun": false, + "fewShotExamples": 3, + "minStrategySamples": 10, + "explorationRate": 0.15 +} +``` + +--- + +### Update Runtime Configuration + +Update runtime configuration values without restarting. + +``` +PUT /api/config +``` + +**Request Body** (partial updates supported): + +```json +{ + "rateLimit": { + "minDelayMs": 45000, + "maxMessagesPerHour": 15 + }, + "dryRun": true, + "reddit": { + "pollInterval": 20 + } +} +``` + +**Response**: + +```json +{ + "success": true, + "config": { ... } +} +``` + +**Errors**: + +```json +{ + "error": "Invalid config: rateLimit.minDelayMs must be a positive integer" +} +``` + +--- + +## Logs & Debugging + +### SSE Log Stream + +Connect to the realtime log stream via Server-Sent Events. + +``` +GET /api/logs/stream +``` + +**Headers**: + +| Header | Value | +|---|---| +| Accept | `text/event-stream` | + +**Response** (streaming): + +``` +event: log +data: {"level":"info","message":"Bot started","timestamp":"2024-01-15T10:30:00.000Z"} + +event: log +data: {"level":"info","message":"Processed post abc123","timestamp":"2024-01-15T10:30:05.000Z"} + +event: log +data: {"level":"warn","message":"AI refusal detected","timestamp":"2024-01-15T10:30:10.000Z"} +``` + +**Client usage (JavaScript)**: + +```javascript +const eventSource = new EventSource('/api/logs/stream'); + +eventSource.onmessage = (event) => { + const log = JSON.parse(event.data); + console.log(log.level, log.message); +}; + +// Close connection +eventSource.close(); +``` + +--- + +### Get Recent Logs + +Retrieve recent log entries via HTTP (non-streaming). + +``` +GET /api/logs +``` + +**Query Parameters**: + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `limit` | number | 100 | Number of entries to return | +| `level` | string | all | Filter by level: `info`, `warn`, `error` | + +**Response**: Same format as `/api/activity`. + +--- + +### Get Rate Limiter Timers + +Get current rate limiter status. + +``` +GET /api/timers +``` + +**Response**: + +```json +{ + "timeUntilSlot": 45, + "hourlyCount": 15, + "maxMessagesPerHour": 20, + "isThrottled": false +} +``` + +--- + +### Debug: Inspect DOM + +Inspect the current chat DOM structure (for debugging shadow DOM issues). + +``` +POST /api/debug/inspect-dom +``` + +**Response**: + +```json +{ + "success": true, + "domStructure": { ... } +} +``` + +--- + +### Debug: Manual Response Check + +Trigger a manual response check (bypasses scheduled interval). + +``` +POST /api/debug/check-responses +``` + +**Response**: + +```json +{ + "success": true, + "responsesFound": 3, + "updated": 2 +} +``` + +--- + +## Error Responses + +All error responses follow this format: + +```json +{ + "error": "Error message describing what went wrong" +} +``` + +### Common Error Codes + +| HTTP Status | Description | Example | +|---|---|---| +| 400 | Bad Request | Invalid query parameter | +| 404 | Not Found | Resource not found | +| 500 | Internal Server Error | Unexpected error | + +--- + +## SSE Events + +The SSE log stream (`/api/logs/stream`) emits the following events: + +### `log` Event + +Emitted for every log entry. + +**Data Format**: + +```json +{ + "level": "info", + "message": "Processed post abc123 from r/gonewild", + "timestamp": "2024-01-15T10:30:00.000Z" +} +``` + +**Log Levels**: + +| Level | Description | +|---|---| +| `info` | Normal operation | +| `warn` | Warning (recoverable issue) | +| `error` | Error (action failed) | +| `debug` | Debug information | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..32ef9be --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,898 @@ +# RedditBot - Software Architecture Documentation + +## Table of Contents + +1. [Project Overview](#project-overview) +2. [System Architecture](#system-architecture) +3. [Directory Structure](#directory-structure) +4. [Component Reference](#component-reference) +5. [Data Flow](#data-flow) +6. [Database Schema](#database-schema) +7. [API Reference](#api-reference) +8. [Configuration](#configuration) +9. [Design Patterns](#design-patterns) +10. [External Integrations](#external-integrations) +11. [Deployment](#deployment) +12. [Technical Debt & Recommendations](#technical-debt--recommendations) + +--- + +## Project Overview + +**RedditBot** is an automated Reddit monitoring and messaging system that: + +- Monitors 30+ NSFW subreddits for image posts +- Uses AI (OpenRouter) to analyze images and detect gender +- Generates personalized direct messages to post authors +- Sends DMs via browser automation (Playwright) +- Monitors for responses and tracks success rates +- Implements A/B testing with explore/exploit strategy selection +- Learns from successful message/response pairs (few-shot learning) +- Provides a real-time web dashboard for monitoring and control + +### Key Capabilities + +| Capability | Description | +|---|---| +| Post Discovery | Polls subreddits for new image posts, filters by gender and spam profiles | +| AI Analysis | Multi-model image analysis with fallback chain via OpenRouter | +| Message Generation | AI-crafted DMs using successful examples as few-shot prompts | +| A/B Testing | Explore/exploit strategy selector optimizes response rates over time | +| Response Monitoring | Background service checks inbox for user replies | +| Chat Automation | Reddit chat scraping via shadow DOM traversal, auto-reply support | +| Media Management | Download, cache, deduplicate, and sync media from conversations | +| Web Dashboard | Real-time monitoring with SSE log streaming, analytics, and controls | + +--- + +## System Architecture + +### High-Level Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ main (index.ts) │ +│ Orchestrator / Entry Point │ +└────────────────────────┬────────────────────────────────────────────┘ + │ + ┌────────────────┼────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Monitor │ │ Processor │ │ Response │ +│ Service │ │ │ │ Checker │ +│ │ │ 1. Download │ │ │ +│ Poll subreddits│ │ 2. Validate │ │ Check inbox │ +│ for new posts │ │ 3. Gender │ │ for replies │ +└───────┬───────┘ │ Filter │ └──────────────┘ + │ │ 4. AI Analysis│ │ + │ │ 5. Strategy │ │ + │ │ Selection │ │ + │ │ 6. Message │ │ + │ │ Generation │ │ + └────┬────┘ └─────────────┘ │ + │ │ + ▼ ▼ +┌───────────────────────┐ ┌───────────────────────┐ +│ Strategy Selector │ │ Chat Service │ +│ │ │ │ +│ A/B Testing │ │ Scrapes chat (shadow │ +│ Explore/Exploit │ │ DOM traversal) │ +│ Variant Selection │ │ Auto-reply, upsell │ +└───────────────────────┘ │ detection │ + └───────────────────────┘ + │ │ + ▼ ▼ +┌───────────────────────┐ ┌───────────────────────┐ +│ Message Sender │ │ Media Service │ +│ (Browser) │ │ │ +│ │ │ Download, cache, │ +│ Playwright DM compose │ │ deduplicate (SHA-1) │ +│ Shadow DOM handling │ │ Matrix thumbnail │ +└───────────────────────┘ │ resolution │ + └───────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────┐ +│ SQLite Database │ +│ │ +│ processed_posts │ sent_messages │ successful_messages │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Web Dashboard (Express) │ +│ │ +│ 25+ API endpoints │ SSE log streaming │ Analytics │ +│ Runtime config │ Bot controls │ Media gallery │ +└─────────────────────────────────────────────────────────┘ +``` + +### Service Communication + +``` +main.ts (Orchestrator) + │ + ├──► Config (Zod-validated YAML + env vars) + │ + ├──► Database (Better-SQLite3) + │ │ + │ └──► PostsRepository + │ └──► MessagesRepository + │ └──► SuccessfulMessagesRepository + │ + ├──► MonitorService ──► RedditApi ──► Playwright (cookies) + │ + ├──► ProcessorService + │ ├──► Image download (axios) + │ ├──► Gender detection (OpenRouter) + │ ├──► Full analysis (OpenRouter) + │ └──► StrategySelector ──► MessagesRepository + │ + ├──► MessageSender (Browser - Playwright) + │ + ├──► ResponseChecker ──► Playwright (inbox navigation) + │ + ├──► ChatService ──► Playwright (chat.reddit.com shadow DOM) + │ + ├──► MediaService ──► Playwright (media download) + │ + ├──► MediaSyncService (background periodic sync) + │ + ├──► AutoReplyService ──► OpenRouter (reply generation) + │ + ├──► RateLimiter (p-queue, jitter) + │ + └──► WebServer (Express, SSE) +``` + +--- + +## Directory Structure + +``` +RedditBot/ +├── .dockerignore # Docker ignore rules +├── .gitignore # Git ignore rules +├── agents.md # Engineering standards & agent guidelines +├── config.yaml # Main YAML configuration file +├── docker-compose.yml # Docker Compose setup +├── DOKPLOY_DEPLOY.md # Dokploy deployment guide +├── Dockerfile # Container build definition +├── package.json # Node.js dependencies & scripts +├── tsconfig.json # TypeScript configuration +├── public/ +│ └── index.html # Single-file web dashboard (HTML+CSS+JS) +├── scripts/ +│ ├── inspect-chat-dom.ts # Debug: inspect chat DOM structure +│ └── login.ts # Manual browser login helper +└── src/ + ├── index.ts # Main entry point / orchestrator (391 lines) + ├── config/ + │ └── index.ts # Config loader + Zod validation schema (157 lines) + ├── database/ + │ ├── client.ts # SQLite setup + migration runner (121 lines) + │ └── repositories.ts # Repository pattern: Posts, Messages, Success (433 lines) + ├── reddit/ + │ └── api.ts # Reddit API client (axios + Playwright cookies) (233 lines) + ├── ai/ + │ └── openrouter.ts # OpenRouter AI client (image analysis, message gen) (307 lines) + ├── browser/ + │ ├── client.ts # Playwright persistent context + login (302 lines) + │ ├── message-page.ts # DM compose page automation + shadow DOM (560 lines) + │ └── media-downloader.ts # Playwright-based media downloader (29 lines) + ├── services/ + │ ├── monitor.ts # Post discovery service (72 lines) + │ ├── processor.ts # Image download + AI + message pipeline (132 lines) + │ ├── response-checker.ts # Inbox response checker (179 lines) + │ ├── strategy-selector.ts # A/B testing explore/exploit (101 lines) + │ ├── chat.ts # Chat scraping + messaging + auto-reply (1123+ lines) + │ ├── media.ts # Media download, caching, indexing (254 lines) + │ ├── media-sync.ts # Periodic background media sync (119 lines) + │ └── auto-reply.ts # Auto-reply to chat conversations (199 lines) + ├── utils/ + │ ├── logger.ts # Winston structured logging (32 lines) + │ ├── rate-limiter.ts # Token-bucket rate limiting (114 lines) + │ ├── retry.ts # Exponential backoff wrapper (28 lines) + │ └── image.ts # Image download + base64 + MIME validation (34 lines) + └── web/ + └── server.ts # Express dashboard + SSE streaming (794 lines) +``` + +--- + +## Component Reference + +### Core Services + +#### MonitorService (`src/services/monitor.ts`) +Discovers new image posts across configured subreddits. + +- Shuffles subreddit order each cycle +- Fetches new posts via `RedditApi` +- Filters out already-processed posts and previously-messaged users +- Returns the single newest valid post per cycle + +#### ProcessorService (`src/services/processor.ts`) +Core processing pipeline for each discovered post. + +1. Downloads image via `ImageService` +2. Validates MIME type and file size +3. Runs quick gender detection (cost-saving pre-filter) +4. Selects A/B strategy variant via `StrategySelector` +5. Runs full AI analysis + message generation via `OpenRouterService` +6. Handles AI refusals gracefully (marks post, tries next) + +#### StrategySelector (`src/services/strategy-selector.ts`) +Implements explore/exploit A/B testing for message variants. + +- Tracks response rates per strategy variant +- Uses minimum sample thresholds before trusting statistics +- Applies random exploration rate for continued discovery +- Selects best variant based on weighted response rates + +#### ResponseChecker (`src/services/response-checker.ts`) +Background service that monitors for user replies. + +- Navigates Reddit inbox via Playwright +- Identifies responses to sent messages +- Updates `got_response` flag in database +- Stores successful message/response pairs for few-shot learning + +#### ChatService (`src/services/chat.ts`) +Most complex service - manages Reddit chat interactions. + +- Scrapes `chat.reddit.com` using JavaScript injection (shadow DOM traversal) +- Manages conversation cache (in-memory + disk JSON) +- Loads full conversation history with pagination +- Sends messages with human-like typing delays +- Verifies message delivery +- Detects upsell/spam keywords in chat +- Parameter: `myUsername` is hardcoded (should be from config) + +#### MediaService (`src/services/media.ts`) +Media download, caching, and indexing. + +- Downloads images from chat conversations +- Resolves Matrix blurred thumbnails to full resolution +- Stores files in `data/media/` directory +- Maintains JSON index with SHA-1 deduplication +- Tracks download status per conversation + +#### MediaSyncService (`src/services/media-sync.ts`) +Periodic background media synchronization. + +- Runs incremental syncs (top 10 chats) or full syncs (all conversations) +- Configurable interval via `mediaSyncInterval` +- Tracks sync status in memory +- Exposed via web dashboard endpoints + +#### AutoReplyService (`src/services/auto-reply.ts`) +Automated reply to Reddit chat conversations. + +- Checks for unread messages in cached conversations +- Detects upsell/spam keywords +- Generates AI reply suggestions via `OpenRouterService` +- Sends replies (auto or pending for manual approval) +- Event-based architecture for dashboard integration + +### External Integrations + +#### RedditApi (`src/reddit/api.ts`) +Axios-based Reddit API client. + +- Fetches posts from subreddits (`/r/{name}/new.json`) +- Validates posts (image-only, skips deleted authors) +- Extracts image URLs with fallback chain: `preview` > `i.redd.it` > `imgur` > `thumbnail` +- Checks user profiles against seller/spam keywords +- Uses Playwright browser cookies for authenticated requests + +#### OpenRouterService (`src/ai/openrouter.ts`) +OpenRouter API client using OpenAI-compatible SDK. + +- `analyzeImage()`: Multi-model image analysis with fallback chain +- `detectGenderOnly()`: Quick cost-saving gender detection +- `generateMessage()`: DM text with few-shot examples from DB +- `generateReply()`: AI chat reply from conversation history +- `detectUpsell()`: Keyword-based spam/OnlyFans detection +- Refusal detection via heuristic patterns + +#### Browser Client (`src/browser/client.ts`) +Playwright persistent context manager. + +- Browser session persistence (cookies/storage) +- Login verification (multiple UI checks + API fallback) +- Automated login flow (form fill, 2FA/captcha detection) +- Debug screenshot capture on login failure + +#### MessagePage (`src/browser/message-page.ts`) +Reddit DM compose page automation. + +- Fills recipient/subject/body fields +- Extensive Shadow DOM support for `faceplate-*` components +- Detects recipient errors, verifies submission success +- Supports dry-run mode for testing + +### Data Layer + +#### Database Client (`src/database/client.ts`) +Better-SQLite3 database initialization. + +- Creates database at configured path +- Enables WAL mode and foreign keys +- Runs migrations on startup +- Safe column-addition for schema evolution + +#### Repositories (`src/database/repositories.ts`) +Three repository classes abstracting SQLite operations. + +- **PostsRepository**: Processed post CRUD, mark sent, update analysis +- **MessagesRepository**: Sent message CRUD, response tracking, strategy stats, subreddit stats, daily trends +- **SuccessfulMessagesRepository**: Successful pairs for few-shot learning, subreddit-weighted retrieval + +### Utilities + +#### Logger (`src/utils/logger.ts`) +Winston structured logging. + +- JSON format for log files +- Colored console output +- File logging: `error.log` and combined log + +#### RateLimiter (`src/utils/rate-limiter.ts`) +Token-bucket rate limiting. + +- p-queue with concurrency=1 +- Minimum delay between messages with random jitter +- Hourly message count tracking +- Timer status exposed to dashboard + +#### Retry (`src/utils/retry.ts`) +Exponential backoff wrapper. + +- p-retry with configurable retries (default 3) +- Backoff range: 5s to 60s +- Logging on retries and failures + +#### Image (`src/utils/image.ts`) +Image processing utilities. + +- Download via axios +- Base64 conversion for AI API +- MIME type validation (image/jpeg, image/png, image/webp) + +### Web Dashboard + +#### WebServer (`src/web/server.ts`) +Express server with 25+ API endpoints. + +- Stats, activity, messages, chats, conversations, media endpoints +- Bot controls (start/stop) +- SSE realtime log streaming +- Analytics: strategy, subreddit, daily trends +- Runtime config management (GET/PUT) +- Debug endpoints + +--- + +## Data Flow + +### Post Processing Pipeline + +``` +1. MonitorService polls subreddits + │ + ├─► RedditApi.fetchPosts() + │ └─► GET /r/{subreddit}/new.json + │ └─► Filter: image posts only, valid author + │ + ├─► Filter: skip processed posts (DB check) + ├─► Filter: skip previously messaged users + │ + └─► Return newest valid post + │ +2. ProcessorService.processPost(post) + │ + ├─► ImageService.downloadImage(url) + │ └─► Validate MIME type, file size + │ + ├─► OpenRouter.detectGenderOnly() + │ └─► Quick pre-filter (cost-saving) + │ └─► Skip if gender doesn't match target + │ + ├─► StrategySelector.selectStrategy(subreddit, author) + │ └─► Explore/exploit algorithm + │ + ├─► OpenRouter.analyzeImage(image) + │ └─► Multi-model fallback chain + │ └─► Detect gender, describe image + │ + ├─► OpenRouter.generateMessage(analysis, title, examples, strategy) + │ └─► Few-shot learning from successful_messages + │ + └─► Mark post as processed in DB + │ +3. MessagePage.sendDM(author, subject, message) + │ + ├─► Navigate to /compose?to={author} + ├─► Fill fields (with shadow DOM handling) + ├─► Submit message + └─► Verify success + │ + └─► Record sent_messages in DB +``` + +### Response Monitoring Flow + +``` +1. ResponseChecker.checkResponses() + │ + ├─► Navigate to /message/inbox + ├─► Parse response items + │ + ├─► For each response: + │ ├─► Match to sent_messages by recipient + timestamp + │ ├─► Update got_response = 1 + │ ├─► Store message/response pair + │ └─► Add to successful_messages (for few-shot learning) + │ + └─► Update strategy stats in DB +``` + +### Chat Interaction Flow + +``` +1. ChatService.fetchChats() + │ + ├─► Navigate to chat.reddit.com + ├─► Traverse shadow DOM via JS injection + ├─► Extract active conversations + └─► Cache in memory + disk JSON + │ +2. ChatService.loadConversation(chatId) + │ + ├─► Load from cache + ├─► Fetch full history (pagination) + └─► Return conversation object + │ +3. ChatService.sendMessage(chatId, text) + │ + ├─► Navigate to chat view + ├─► Fill message input (shadow DOM) + ├─► Apply typing delay (human-like) + ├─► Submit + └─► Verify delivery +``` + +--- + +## Database Schema + +### Table: `processed_posts` + +Tracks all posts that have been discovered and processed. + +| Column | Type | Constraints | Description | +|---|---|---|---| +| `id` | TEXT | PRIMARY KEY | Reddit post ID | +| `subreddit` | TEXT | NOT NULL | Source subreddit | +| `author` | TEXT | NOT NULL | Post author username | +| `title` | TEXT | | Post title | +| `image_url` | TEXT | | Original image URL | +| `image_analysis` | TEXT | | AI-generated image description | +| `generated_message` | TEXT | | AI-generated DM text | +| `processed_at` | INTEGER | NOT NULL | Unix timestamp | +| `message_sent` | INTEGER | DEFAULT 0 | 0 = not sent, 1 = sent | +| `error` | TEXT | | Error message if processing failed | + +**Indexes**: `idx_posts_subreddit`, `idx_posts_author` + +### Table: `sent_messages` + +Tracks all DMs sent by the bot. + +| Column | Type | Constraints | Description | +|---|---|---|---| +| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT | Internal ID | +| `post_id` | TEXT | FK → processed_posts.id | Source post | +| `recipient` | TEXT | NOT NULL | Recipient username | +| `subject` | TEXT | DEFAULT 'Stunning' | DM subject line | +| `message` | TEXT | NOT NULL | DM body | +| `sent_at` | INTEGER | NOT NULL | Unix timestamp | +| `strategy_id` | TEXT | | A/B test variant identifier | +| `got_response` | INTEGER | DEFAULT 0 | 0 = no response, 1 = responded | +| `response_checked_at` | INTEGER | | Timestamp of last response check | + +**Indexes**: `idx_messages_recipient`, `idx_messages_strategy`, `idx_messages_response` + +### Table: `successful_messages` + +Stores successful message/response pairs for few-shot learning. + +| Column | Type | Constraints | Description | +|---|---|---|---| +| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT | Internal ID | +| `sent_message_id` | INTEGER | FK → sent_messages.id (nullable) | Related message | +| `original_message` | TEXT | NOT NULL | The DM that got a response | +| `image_analysis` | TEXT | | Context from image analysis | +| `post_title` | TEXT | | Original post title | +| `response_snippet` | TEXT | | First reply text | +| `subreddit` | TEXT | | For weighted example selection | +| `created_at` | INTEGER | NOT NULL | Unix timestamp | + +**Indexes**: `idx_success_subreddit`, `idx_success_message` + +### Migration Strategy + +The database uses progressive migrations that: +- Run on every startup +- Use `ALTER TABLE ADD COLUMN IF NOT EXISTS` for safe schema evolution +- Create indexes with `IF NOT EXISTS` for idempotency +- Enable WAL mode and foreign keys for performance and integrity + +--- + +## API Reference + +### Web Dashboard Endpoints + +All endpoints are served by the Express server at `http://localhost:{port}`. + +#### Bot Status & Control + +| Method | Endpoint | Description | +|---|---|---| +| GET | `/api/stats` | Bot statistics (running state, post/message/response counts, response rate) | +| POST | `/api/start` | Start the bot | +| POST | `/api/stop` | Stop the bot | + +#### Activity & Messages + +| Method | Endpoint | Description | +|---|---|---| +| GET | `/api/activity` | Recent activity log entries | +| GET | `/api/posts/by-author/:username` | Look up processed posts by user | +| GET | `/api/messages` | Recent sent messages | +| GET | `/api/successful` | Successful message/response pairs | + +#### Chat & Conversations + +| Method | Endpoint | Description | +|---|---|---| +| GET | `/api/chats` | Active cached conversations | +| POST | `/api/chats/fetch-all` | Fetch all chats from Reddit | +| GET | `/api/conversations` | All cached conversations | +| GET | `/api/conversations/:id` | Single conversation detail | +| POST | `/api/conversations/:id/suggest-reply` | AI suggest reply for conversation | +| POST | `/api/conversations/:id/send-reply` | Send chat reply | +| POST | `/api/conversations/:id/approve-reply` | Approve pending auto-reply | +| POST | `/api/conversations/:id/reject-reply` | Reject pending auto-reply | +| POST | `/api/conversations/:id/dismiss-upsell` | Dismiss upsell flag | + +#### Media + +| Method | Endpoint | Description | +|---|---|---| +| GET | `/api/media` | Downloaded media gallery | +| GET | `/api/media/sync` | Media sync status | +| POST | `/api/media/sync` | Trigger incremental sync (top 10 chats) | +| POST | `/api/media/sync/full` | Trigger full sync (all conversations) | + +#### Analytics + +| Method | Endpoint | Description | +|---|---|---| +| GET | `/api/analytics/by-subreddit` | Response rate by subreddit | +| GET | `/api/analytics/by-strategy` | Response rate by strategy variant | +| GET | `/api/analytics/trend` | Daily response rate trend | + +#### Configuration + +| Method | Endpoint | Description | +|---|---|---| +| GET | `/api/config` | Current runtime configuration | +| PUT | `/api/config` | Update runtime config (rate limits, dry run, poll interval) | + +#### Logs & Debugging + +| Method | Endpoint | Description | +|---|---|---| +| GET | `/api/logs/stream` | SSE realtime log stream | +| GET | `/api/logs` | Recent logs (HTTP) | +| GET | `/api/timers` | Rate limiter timer status | +| POST | `/api/debug/inspect-dom` | Debug: inspect chat DOM structure | +| POST | `/api/debug/check-responses` | Debug: manual response check | + +--- + +## Configuration + +### Configuration Sources (Priority Order) + +1. **`config.yaml`** - Primary configuration file (project root) +2. **Environment Variables** - Override specific fields: + - `OPENROUTER_API_KEY` - OpenRouter API key + - `REDDIT_USERNAME` - Reddit username + - `REDDIT_PASSWORD` - Reddit password + - `BROWSER_HEADLESS` - Browser headless mode (`true`/`false`) + +### Configuration Schema (Zod-Validated) + +```typescript +{ + reddit: { + subreddits: string[] // Subreddits to monitor + pollInterval: number // Minutes between polls + targetGender: string // Target gender filter + sellerKeywords: string[] // Keywords for spam/seller detection + } + openrouter: { + apiKey: string // OpenRouter API key + model: string // Primary AI model + fallbackModels: string[] // Fallback models on refusal + systemPrompt: string // System prompt for analysis + messagePrompt: string // System prompt for message generation + } + rateLimit: { + minDelayMs: number // Minimum delay between messages + maxMessagesPerHour: number // Hourly message cap + jitterMs: number // Random jitter range + } + browser: { + headless: boolean // Headless mode + userDataDir: string // Persistent session directory + } + database: { + path: string // SQLite database path + } + mediaSync: { + enabled: boolean // Enable media sync + intervalMinutes: number // Sync interval + } + autoReply: { + enabled: boolean // Enable auto-reply + autoSend: boolean // Auto-send or pending approval + checkInterval: number // Check interval in minutes + } + strategyVariants: { + name: string // Variant name + prompt: string // Message style prompt + enabled: boolean // Enable/disable variant + }[] + web: { + port: number // Dashboard port + } +} +``` + +### Runtime Configuration + +The following settings can be modified via the web dashboard without restarting: + +- `rateLimit.minDelayMs` - Message delay +- `rateLimit.maxMessagesPerHour` - Hourly cap +- `rateLimit.jitterMs` - Jitter range +- `reddit.pollInterval` - Poll frequency +- `dryRun` - Dry run mode (log only, no actual sends) + +--- + +## Design Patterns + +### Repository Pattern + +All database operations are abstracted behind three repository classes: + +- `PostsRepository` - Post lifecycle management +- `MessagesRepository` - Message tracking and analytics +- `SuccessfulMessagesRepository` - Few-shot learning data + +Benefits: +- Single source of truth for data access +- Easy to swap implementations (e.g., different databases) +- Consistent error handling and query patterns + +### Dependency Injection + +Services receive dependencies via constructor parameters: + +```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 + ) {} +} +``` + +Benefits: +- Loose coupling between components +- Easy to mock dependencies for testing +- Clear dependency graph + +### Strategy Pattern + +`StrategySelector` implements A/B testing with multiple message variants: + +```typescript +class StrategySelector { + selectStrategy(subreddit: string, author: string): string { + // Explore/exploit algorithm + // Returns variant name based on response rates + } +} +``` + +Benefits: +- Swappable message strategies +- Measurable performance per variant +- Configurable exploration rate + +### Factory Pattern + +Configuration and database creation use factory functions: + +- `loadConfig()` - Loads, merges env vars, validates with Zod +- `createDatabase()` - Initializes SQLite, runs migrations + +### Observer Pattern + +SSE (Server-Sent Events) for realtime log streaming: + +```typescript +// Logger writes to a shared buffer +logger.info("Message sent to user123"); + +// WebServer subscribes clients via SSE +app.get("/api/logs/stream", (req, res) => { + // Push new log entries to connected clients +}); +``` + +### Circuit Breaker / Retry + +`withRetry()` wrapper for resilient external calls: + +```typescript +await withRetry( + () => redditApi.fetchPosts(subreddit), + { retries: 3, logger } +); +``` + +Multiple fallback chains: +- AI model fallback (primary → fallback models) +- Image URL fallback (preview → i.redd.it → imgur → thumbnail) + +### Rate Limiter Pattern + +Token-bucket-like implementation: + +```typescript +class RateLimiter { + async waitForSlot(): Promise { + // Enforce minimum delay + jitter + // Track hourly counts + // Block if hourly cap reached + } +} +``` + +--- + +## External Integrations + +### Reddit API + +- **Authentication**: Playwright browser cookies (persistent session) +- **Endpoints Used**: + - `GET /r/{name}/new.json` - Fetch new posts + - `GET /user/{username}/profile` - Check for seller/spam indicators + - `GET /message/inbox` - Check for responses + - `GET chat.reddit.com` - Chat scraping +- **Rate Limits**: Enforced by `RateLimiter` utility + +### OpenRouter AI + +- **SDK**: OpenAI-compatible (`openai` npm package) +- **Base URL**: `https://openrouter.ai/api/v1` +- **Endpoints Used**: + - `POST /chat/completions` - Image analysis, message generation, replies +- **Model Fallback**: Primary model → fallback models on refusal +- **Cost Optimization**: Quick gender detection before full analysis + +### Playwright Browser + +- **Context**: Persistent (cookies/session storage saved to disk) +- **Usage**: + - Automated Reddit login + - DM compose page automation + - Inbox navigation for response checking + - Chat page scraping (shadow DOM traversal) + - Media file downloads +- **Shadow DOM**: JavaScript injection for `faceplate-*` component traversal + +--- + +## Deployment + +### Docker + +The project includes full Docker support: + +- **`Dockerfile`** - Multi-stage build with Node.js +- **`docker-compose.yml`** - Service orchestration +- **`.dockerignore`** - Excludes unnecessary files + +### Dokploy + +Deployment documentation is provided in `DOKPLOY_DEPLOY.md`. + +### Required Environment Variables + +| Variable | Required | Description | +|---|---|---| +| `OPENROUTER_API_KEY` | Yes | OpenRouter API key for AI | +| `REDDIT_USERNAME` | Yes | Reddit username | +| `REDDIT_PASSWORD` | Yes | Reddit password | +| `BROWSER_HEADLESS` | No | Browser headless mode (default: `true`) | + +### Pre-Deployment Checklist + +1. Set up `config.yaml` with target subreddits and settings +2. Set environment variables (or use `.env` file) +3. Run `npm install` to install dependencies +4. Run `npm run build` to compile TypeScript +5. Run `scripts/login.ts` once to establish browser session +6. Start the bot (via `npm start` or Docker) + +--- + +## Technical Debt & Recommendations + +### Critical + +1. **No Tests** - Zero test coverage. Add unit tests for services, especially: + - `StrategySelector` (complex algorithm) + - `ChatService` (shadow DOM logic) + - `OpenRouterService` (model fallback logic) + +2. **No README.md** - No project documentation for new developers. + +3. **Hardcoded Username** - `ChatService` has `myUsername = 'Upset_Marsupial1006'` hardcoded in JS evaluation scripts. Should be parameterized from config. + +### High Priority + +4. **God Class** - `main.ts` (391 lines) creates all dependencies and wires everything. Consider extracting an `AppContainer` or `DependencyContainer`. + +5. **ChatService Size** - 1123+ lines. Consider splitting into: + - `ChatScraper` (navigation + DOM traversal) + - `ConversationCache` (cache management) + - `ChatMessenger` (sending + verification) + +6. **No .env.example** - No template showing required environment variables. + +### Medium Priority + +7. **Single-Page Dashboard** - `public/index.html` is ~2000+ lines with embedded CSS/JS. Consider a proper frontend framework. + +8. **WebServer Size** - 794 lines. Consider splitting routes into separate files. + +9. **Config Mutability** - Runtime config updates via `PUT /api/config` modify the in-memory config object. Consider a proper config service with validation on updates. + +10. **Error Handling** - Some services have basic try/catch. Consider structured error types with retry logic. + +### Low Priority + +11. **Logging** - Winston is well-configured, but consider adding request IDs for tracing. + +12. **Type Safety** - Some `page.evaluate()` calls use `any` types. Consider stricter typing for DOM structures. + +13. **Media Storage** - Media files stored in `data/media/` with no cleanup policy. Consider retention policies. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..88f8764 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,488 @@ +# Configuration Guide + +Complete reference for configuring RedditBot. + +--- + +## Table of Contents + +1. [Configuration Overview](#configuration-overview) +2. [Configuration Files](#configuration-files) +3. [Environment Variables](#environment-variables) +4. [config.yaml Reference](#configyaml-reference) +5. [Runtime Configuration](#runtime-configuration) +6. [Configuration Validation](#configuration-validation) +7. [Troubleshooting](#troubleshooting) + +--- + +## Configuration Overview + +RedditBot uses a layered configuration approach: + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Priority Order (High → Low) │ +├──────────────────────────────────────────────────────────────┤ +│ 1. Environment Variables │ +│ (OPENROUTER_API_KEY, REDDIT_USERNAME, etc.) │ +│ │ +│ 2. config.yaml │ +│ (Primary configuration file) │ +│ │ +│ 3. Defaults │ +│ (Hardcoded defaults in code) │ +└──────────────────────────────────────────────────────────────┘ +``` + +Configuration is loaded at startup by `loadConfig()` in `src/config/index.ts`, which: + +1. Reads `config.yaml` from the project root +2. Merges environment variable overrides +3. Validates the entire configuration tree using Zod +4. Returns a strongly-typed `Config` object + +--- + +## Configuration Files + +### config.yaml + +The primary configuration file, located at the project root: + +``` +RedditBot/ +├── config.yaml # ← Main configuration +├── package.json +├── tsconfig.json +└── src/ +``` + +### Data Directory + +Runtime data is stored in `data/` (created automatically): + +``` +data/ +├── reddit-bot.db # SQLite database +├── browser/ # Browser session data (cookies, storage) +├── media/ # Downloaded media files +│ ├── index.json # Media index (SHA-1 → filename) +│ ├── abc123.jpg # Media file 1 +│ └── def456.png # Media file 2 +└── chats/ # Cached conversation JSON files + ├── chat_abc123.json + └── chat_def456.json +``` + +--- + +## Environment Variables + +Environment variables override corresponding values in `config.yaml`. + +| Variable | Required | Type | Description | Example | +|---|---|---|---|---| +| `OPENROUTER_API_KEY` | Yes | string | OpenRouter API key for AI services | `sk-or-v1-abc123...` | +| `REDDIT_USERNAME` | Yes | string | Reddit username for authentication | `MyBotUser` | +| `REDDIT_PASSWORD` | Yes | string | Reddit password for authentication | `secret123` | +| `BROWSER_HEADLESS` | No | boolean | Run browser in headless mode | `true` or `false` | + +### Setting Environment Variables + +**Option 1: `.env` file** (recommended for development) + +Create a `.env` file in the project root: + +```env +OPENROUTER_API_KEY=sk-or-v1-abc123... +REDDIT_USERNAME=MyBotUser +REDDIT_PASSWORD=secret123 +BROWSER_HEADLESS=true +``` + +> **Note**: Add `.env` to `.gitignore` - never commit credentials. + +**Option 2: Shell export** + +```bash +export OPENROUTER_API_KEY=sk-or-v1-abc123... +export REDDIT_USERNAME=MyBotUser +export REDDIT_PASSWORD=secret123 +``` + +**Option 3: Docker** + +Pass via `docker-compose.yml` or Docker CLI: + +```yaml +environment: + - OPENROUTER_API_KEY=sk-or-v1-abc123... + - REDDIT_USERNAME=MyBotUser + - REDDIT_PASSWORD=secret123 +``` + +--- + +## config.yaml Reference + +Complete reference for every configuration option. + +### Reddit Settings + +```yaml +reddit: + # Subreddits to monitor for new posts + subreddits: + - gonewild + - ratemyboobs + - HomemadeNsfw + - realgirls + - plussize_gonewild + # ... add or remove subreddits + + # Minutes between polling cycles + pollInterval: 15 + + # Target gender for image analysis filtering + # Options: "F" (female), "M" (male) + targetGender: "F" + + # Keywords used to detect sellers/spam profiles + # Posts from users matching these keywords are skipped + sellerKeywords: + - seller + - onlyfans + - link in bio + - check my profile + - promo + - discount + - affiliate +``` + +### OpenRouter AI Settings + +```yaml +openrouter: + # OpenRouter API key (can also be set via OPENROUTER_API_KEY env var) + apiKey: "sk-or-v1-abc123..." + + # Primary AI model for image analysis + # Models must be available on OpenRouter + model: "meta-llama/llama-3.2-90b-vision-preview" + + # Fallback models tried if the primary model refuses or fails + fallbackModels: + - "google/gemini-2.0-flash-001" + - "meta-llama/llama-3.2-11b-vision-instruct" + + # Quick detection model (cheaper/faster, used for gender pre-filter) + quickModel: "google/gemini-2.0-flash-lite-001" + + # System prompt for image analysis + # The AI will describe the image and detect gender + systemPrompt: | + Analyze this image and provide: + 1. Gender detected (F or M) + 2. Detailed description of the image content + 3. Notable features (hair color, clothing, setting, etc.) + + Return as JSON: {"gender": "F", "description": "...", "features": [...]} + + # System prompt for message generation + # Used when crafting DMs to post authors + messagePrompt: | + Generate a natural, friendly DM for this Reddit post. + + Image analysis: {description} + Post title: {title} + Strategy: {strategy} + + Guidelines: + - Be complimentary but not creepy + - Reference something specific from the image + - Keep it under 2 sentences + - Sound like a genuine person, not a bot + - Do NOT ask for anything in return +``` + +### Rate Limiting + +```yaml +rateLimit: + # Minimum delay between messages (milliseconds) + # Higher values reduce detection risk + minDelayMs: 30000 + + # Maximum messages per hour + # Bot will pause when this limit is reached + maxMessagesPerHour: 20 + + # Random jitter added to delay (milliseconds) + # Adds unpredictability to timing + jitterMs: 15000 +``` + +**Timing example with defaults:** +``` +Minimum delay: 30,000ms (30 seconds) +Jitter: 0 to 15,000ms (0-15 seconds) +Effective delay: 30-45 seconds between messages +Max messages: 20 per hour +``` + +### Browser Settings + +```yaml +browser: + # Run browser in headless mode (no UI) + # Set to false for debugging + headless: true + + # Directory for persistent browser data + # Cookies and session storage are saved here + userDataDir: "./data/browser" +``` + +**Browser session persistence:** + +``` +data/browser/ +├── Default/ +│ ├── Cookies # Reddit session cookies +│ ├── Local Storage # Local storage data +│ └── Preferences # Browser preferences +├── Login Data # Saved login info +└── Preferences # Browser state +``` + +> **Important**: Keep this directory intact. Deleting it requires re-login. + +### Database Settings + +```yaml +database: + # Path to SQLite database file + path: "./data/reddit-bot.db" +``` + +The database is auto-created on first run. Migrations run automatically on startup. + +### Media Sync Settings + +```yaml +mediaSync: + # Enable periodic media synchronization + enabled: true + + # Interval between sync cycles (minutes) + intervalMinutes: 30 +``` + +**Sync behavior:** +- **Incremental sync** (automatic): Downloads media from top 10 conversations +- **Full sync** (manual via dashboard): Downloads media from all conversations + +### Auto-Reply Settings + +```yaml +autoReply: + # Enable automatic chat replies + enabled: true + + # Auto-send replies (true) or mark as pending for approval (false) + autoSend: false + + # Minutes between checking for new messages + checkInterval: 10 +``` + +**Reply modes:** + +| autoSend | Behavior | +|---|---| +| `true` | Bot sends AI-generated replies automatically | +| `false` | Bot suggests replies, user approves via dashboard | + +### Strategy Variants (A/B Testing) + +```yaml +strategyVariants: + # Variant 1: Compliment-based + - name: "compliment" + prompt: | + Write a friendly compliment about the person's appearance. + Be genuine and specific. Keep it short and natural. + enabled: true + + # Variant 2: Question-based + - name: "question" + prompt: | + Write a friendly message that includes a question. + Engage them in conversation naturally. + enabled: true + + # Variant 3: Casual observation + - name: "observation" + prompt: | + Write a casual observation about the photo or setting. + Sound like you're commenting to a friend. + enabled: true +``` + +### Web Dashboard Settings + +```yaml +web: + # Port for the web dashboard + port: 3000 +``` + +### Global Settings + +```yaml +# Global dry run mode +# When true, all actions are logged but not actually performed +dryRun: false + +# Number of successful message examples to include in prompts +fewShotExamples: 3 + +# Minimum responses needed before trusting strategy stats +minStrategySamples: 10 + +# Exploration rate for A/B testing (0.0 to 1.0) +# 0.15 = 15% of messages use random variants (exploration) +explorationRate: 0.15 +``` + +--- + +## Runtime Configuration + +The following settings can be modified via the web dashboard without restarting the bot: + +| Setting | Endpoint | Type | Description | +|---|---|---|---| +| `rateLimit.minDelayMs` | `PUT /api/config` | number | Message delay | +| `rateLimit.maxMessagesPerHour` | `PUT /api/config` | number | Hourly cap | +| `rateLimit.jitterMs` | `PUT /api/config` | number | Jitter range | +| `reddit.pollInterval` | `PUT /api/config` | number | Poll frequency | +| `dryRun` | `PUT /api/config` | boolean | Dry run mode | + +### Updating Runtime Config + +Via dashboard: +1. Open the settings modal +2. Modify desired values +3. Click "Save" + +Via API: +```bash +curl -X PUT http://localhost:3000/api/config \ + -H "Content-Type: application/json" \ + -d '{ + "rateLimit": { + "minDelayMs": 45000, + "maxMessagesPerHour": 15, + "jitterMs": 20000 + }, + "dryRun": true + }' +``` + +> **Note**: Runtime changes are in-memory only. They are lost when the bot restarts. To make changes persistent, update `config.yaml`. + +--- + +## Configuration Validation + +All configuration is validated using Zod at startup. Invalid configuration will cause the bot to exit with a descriptive error. + +### Validation Rules + +| Field | Validation | +|---|---| +| `reddit.subreddits` | Must be non-empty array of strings | +| `reddit.pollInterval` | Must be positive integer | +| `reddit.targetGender` | Must be "F" or "M" | +| `openrouter.apiKey` | Must be non-empty string | +| `openrouter.model` | Must be non-empty string | +| `openrouter.fallbackModels` | Must be non-empty array | +| `rateLimit.minDelayMs` | Must be positive integer | +| `rateLimit.maxMessagesPerHour` | Must be positive integer | +| `rateLimit.jitterMs` | Must be non-negative integer | +| `browser.headless` | Must be boolean | +| `browser.userDataDir` | Must be non-empty string | +| `database.path` | Must be non-empty string | +| `mediaSync.enabled` | Must be boolean | +| `mediaSync.intervalMinutes` | Must be positive integer (if enabled) | +| `autoReply.enabled` | Must be boolean | +| `autoReply.autoSend` | Must be boolean | +| `autoReply.checkInterval` | Must be positive integer (if enabled) | +| `strategyVariants` | Must be array with name + prompt | +| `web.port` | Must be integer 1-65535 | +| `dryRun` | Must be boolean | + +### Common Validation Errors + +**Error**: `Invalid config: openrouter.apiKey must be a string` +**Fix**: Set `OPENROUTER_API_KEY` environment variable or add `apiKey` to `config.yaml` + +**Error**: `Invalid config: reddit.subreddits must be a non-empty array` +**Fix**: Add at least one subreddit to `reddit.subreddits` in `config.yaml` + +**Error**: `Invalid config: browser.userDataDir must be a string` +**Fix**: Ensure `browser.userDataDir` is set in `config.yaml` + +--- + +## Troubleshooting + +### Configuration Not Loading + +**Symptom**: Bot fails to start with validation errors. + +**Steps**: +1. Verify `config.yaml` is in the project root +2. Check YAML syntax (use a YAML linter) +3. Verify environment variables are set correctly +4. Check bot logs for specific validation errors + +### Credentials Not Working + +**Symptom**: Browser login fails. + +**Steps**: +1. Verify `REDDIT_USERNAME` and `REDDIT_PASSWORD` are correct +2. Check that the account is not locked or requires 2FA +3. Try manual login via `npm run login` +4. Ensure `BROWSER_HEADLESS=false` for debugging login issues + +### AI Not Working + +**Symptom**: Image analysis fails or returns errors. + +**Steps**: +1. Verify `OPENROUTER_API_KEY` is valid +2. Check API key has sufficient balance +3. Verify `openrouter.model` is available on OpenRouter +4. Check fallback models are set correctly +5. Review logs for specific AI errors + +### Browser Session Expired + +**Symptom**: Bot stops with "Browser session expired" error. + +**Steps**: +1. Delete `data/browser/` directory +2. Run `npm run login` to re-authenticate +3. Restart the bot + +### Port Already in Use + +**Symptom**: Dashboard fails to start with "Port already in use". + +**Steps**: +1. Change `web.port` in `config.yaml` +2. Or kill the process using the port: `lsof -i :3000` diff --git a/docs/data-flow.md b/docs/data-flow.md new file mode 100644 index 0000000..c0bb814 --- /dev/null +++ b/docs/data-flow.md @@ -0,0 +1,875 @@ +# Data Flow Documentation + +Complete documentation of data flows through the RedditBot system. + +--- + +## 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) │ + └──────────────┘ +``` + +--- + +## Post Processing Flow + +The primary data flow for discovering, analyzing, and messaging about new posts. + +### Stage 1: Discovery + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 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 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Stage 2: Image Processing + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 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..." │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Stage 3: Gender Detection (Pre-Filter) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 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") │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Stage 4: Strategy Selection + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 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" │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Stage 5: Full AI Analysis + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 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..." }│ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Stage 6: Message Generation + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 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 web dashboard. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 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 │ +│ │ +│ ──── 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 │ +│ │ +│ ──── FRONTEND (public/index.html) ─────────────────────────── │ +│ │ +│ 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 +``` + +### Browser Session Recovery + +``` +1. BrowserClient.verifySession() returns false + │ + ├─► Check for expired cookies + │ + ├─► Attempt automated re-login + │ ├─► BrowserClient.login() + │ └─► If success: continue + │ + └─► If re-login fails: + ├─► Log error: "Browser session expired" + ├─► Stop bot + └─► Require manual login (scripts/login.ts) +``` + +--- + +## 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) +``` diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..e9156a9 --- /dev/null +++ b/docs/database.md @@ -0,0 +1,550 @@ +# Database Schema + +Complete reference for the SQLite database schema, migrations, and query patterns. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Database File](#database-file) +3. [Schema](#schema) +4. [Indexes](#indexes) +5. [Migrations](#migrations) +6. [Query Patterns](#query-patterns) +7. [Repository Methods](#repository-methods) +8. [Data Retention](#data-retention) + +--- + +## Overview + +RedditBot uses SQLite (via `better-sqlite3`) for persistent storage. The database tracks processed posts, sent messages, and successful message/response pairs. + +**Features**: +- WAL mode for better concurrent read performance +- Foreign key constraints enabled +- Progressive migrations for schema evolution +- Repository pattern for data access + +--- + +## Database File + +**Default path**: `./data/reddit-bot.db` + +Configured in `config.yaml`: + +```yaml +database: + path: "./data/reddit-bot.db" +``` + +The database file is auto-created on first run. + +--- + +## Schema + +### Table: `processed_posts` + +Tracks all posts that have been discovered and processed. + +```sql +CREATE TABLE processed_posts ( + id TEXT PRIMARY KEY, -- Reddit post ID + subreddit TEXT NOT NULL, -- Source subreddit + author TEXT NOT NULL, -- Post author username + title TEXT, -- Post title + image_url TEXT, -- Original image URL + image_analysis TEXT, -- AI-generated JSON description + generated_message TEXT, -- AI-generated DM text + processed_at INTEGER NOT NULL, -- Unix timestamp + message_sent INTEGER DEFAULT 0, -- 0 = not sent, 1 = sent + error TEXT -- Error message if processing failed +); +``` + +**Column Details**: + +| Column | Type | Constraints | Description | +|---|---|---|---| +| `id` | TEXT | PRIMARY KEY | Reddit post ID (e.g., "abc123") | +| `subreddit` | TEXT | NOT NULL | Source subreddit name | +| `author` | TEXT | NOT NULL | Post author username | +| `title` | TEXT | | Post title (nullable if unavailable) | +| `image_url` | TEXT | | Original image URL | +| `image_analysis` | TEXT | | JSON string from AI analysis | +| `generated_message` | TEXT | | The DM text that was generated | +| `processed_at` | INTEGER | NOT NULL | Unix timestamp when processed | +| `message_sent` | INTEGER | DEFAULT 0 | 0 = not sent, 1 = successfully sent | +| `error` | TEXT | | Error message if processing failed | + +**Example Row**: + +```json +{ + "id": "abc123", + "subreddit": "gonewild", + "author": "user123", + "title": "Summer vibes", + "image_url": "https://i.redd.it/abc123.jpg", + "image_analysis": "{\"gender\":\"F\",\"description\":\"Young woman...\"}", + "generated_message": "Hey, you look amazing...", + "processed_at": 1716234567, + "message_sent": 1, + "error": null +} +``` + +--- + +### Table: `sent_messages` + +Tracks all DMs sent by the bot. + +```sql +CREATE TABLE sent_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id TEXT NOT NULL, + recipient TEXT NOT NULL, + subject TEXT DEFAULT 'Stunning', + message TEXT NOT NULL, + sent_at INTEGER NOT NULL, + strategy_id TEXT, + got_response INTEGER DEFAULT 0, + response_checked_at INTEGER, + FOREIGN KEY (post_id) REFERENCES processed_posts(id) +); +``` + +**Column Details**: + +| Column | Type | Constraints | Description | +|---|---|---|---| +| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT | Internal ID | +| `post_id` | TEXT | NOT NULL, FK | Source post ID | +| `recipient` | TEXT | NOT NULL | Recipient username | +| `subject` | TEXT | DEFAULT 'Stunning' | DM subject line | +| `message` | TEXT | NOT NULL | DM body text | +| `sent_at` | INTEGER | NOT NULL | Unix timestamp when sent | +| `strategy_id` | TEXT | | A/B test variant name | +| `got_response` | INTEGER | DEFAULT 0 | 0 = no response, 1 = responded | +| `response_checked_at` | INTEGER | | Timestamp of last response check | + +**Example Row**: + +```json +{ + "id": 1, + "post_id": "abc123", + "recipient": "user123", + "subject": "Stunning", + "message": "Hey, you look amazing...", + "sent_at": 1716234567, + "strategy_id": "compliment", + "got_response": 1, + "response_checked_at": 1716238167 +} +``` + +--- + +### Table: `successful_messages` + +Stores successful message/response pairs for few-shot learning. + +```sql +CREATE TABLE successful_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sent_message_id INTEGER, + original_message TEXT NOT NULL, + image_analysis TEXT, + post_title TEXT, + response_snippet TEXT, + subreddit TEXT, + created_at INTEGER NOT NULL, + FOREIGN KEY (sent_message_id) REFERENCES sent_messages(id) +); +``` + +**Column Details**: + +| Column | Type | Constraints | Description | +|---|---|---|---| +| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT | Internal ID | +| `sent_message_id` | INTEGER | FK, nullable | Related sent message ID | +| `original_message` | TEXT | NOT NULL | The DM that got a response | +| `image_analysis` | TEXT | | Context from image analysis | +| `post_title` | TEXT | | Original post title | +| `response_snippet` | TEXT | | First reply text | +| `subreddit` | TEXT | | For weighted example selection | +| `created_at` | INTEGER | NOT NULL | Unix timestamp | + +**Example Row**: + +```json +{ + "id": 1, + "sent_message_id": 1, + "original_message": "Hey, you look amazing...", + "image_analysis": "{\"gender\":\"F\",\"description\":\"...\"}", + "post_title": "Summer vibes", + "response_snippet": "Thank you so much! 😊", + "subreddit": "gonewild", + "created_at": 1716238167 +} +``` + +--- + +## Indexes + +### `processed_posts` Indexes + +```sql +CREATE INDEX idx_posts_subreddit ON processed_posts(subreddit); +CREATE INDEX idx_posts_author ON processed_posts(author); +``` + +**Purpose**: Fast lookup by subreddit (for processing stats) and by author (for skip-checking). + +--- + +### `sent_messages` Indexes + +```sql +CREATE INDEX idx_messages_recipient ON sent_messages(recipient); +CREATE INDEX idx_messages_strategy ON sent_messages(strategy_id); +CREATE INDEX idx_messages_response ON sent_messages(got_response); +``` + +**Purpose**: +- `idx_messages_recipient`: Fast lookup when matching responses +- `idx_messages_strategy`: Analytics by strategy variant +- `idx_messages_response`: Filter for responded/not-responded + +--- + +### `successful_messages` Indexes + +```sql +CREATE INDEX idx_success_subreddit ON successful_messages(subreddit); +CREATE INDEX idx_success_message ON successful_messages(sent_message_id); +``` + +**Purpose**: +- `idx_success_subreddit`: Weighted example selection by subreddit +- `idx_success_message`: Link back to sent message + +--- + +## Migrations + +Migrations are defined in `src/database/client.ts` and run automatically on every startup. + +### Migration Logic + +```typescript +// Pseudo-code for migration process +function runMigrations(db: Database) { + // Enable features + db.exec("PRAGMA journal_mode=WAL;"); + db.exec("PRAGMA foreign_keys=ON;"); + + // Create tables if they don't exist + db.exec(`CREATE TABLE IF NOT EXISTS processed_posts (...)`); + db.exec(`CREATE TABLE IF NOT EXISTS sent_messages (...)`); + db.exec(`CREATE TABLE IF NOT EXISTS successful_messages (...)`); + + // Create indexes if they don't exist + db.exec(`CREATE INDEX IF NOT EXISTS idx_posts_subreddit ON processed_posts(subreddit)`); + // ... more indexes + + // Safe column additions (for schema evolution) + db.exec(`ALTER TABLE processed_posts ADD COLUMN error TEXT`); +} +``` + +### Migration Safety + +- All `CREATE TABLE` statements use `IF NOT EXISTS` +- All `CREATE INDEX` statements use `IF NOT EXISTS` +- Column additions use `ALTER TABLE ADD COLUMN IF NOT EXISTS` pattern +- Migrations are idempotent (safe to run multiple times) + +### Adding New Columns + +When adding new columns to existing tables: + +```typescript +// In src/database/client.ts +const tableInfo = db.prepare(`PRAGMA table_info(processed_posts)`).all(); +const hasErrorColumn = tableInfo.some((col: any) => col.name === 'error'); + +if (!hasErrorColumn) { + db.exec("ALTER TABLE processed_posts ADD COLUMN error TEXT"); +} +``` + +--- + +## Query Patterns + +### Post Processing + +**Check if post was already processed**: + +```sql +SELECT id FROM processed_posts WHERE id = ?; +``` + +**Mark post as processed**: + +```sql +INSERT OR REPLACE INTO processed_posts + (id, subreddit, author, title, image_url, image_analysis, + generated_message, processed_at, message_sent, error) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); +``` + +**Mark post as sent**: + +```sql +UPDATE processed_posts +SET message_sent = 1 +WHERE id = ?; +``` + +**Update post with analysis**: + +```sql +UPDATE processed_posts +SET image_analysis = ?, generated_message = ? +WHERE id = ?; +``` + +--- + +### Message Tracking + +**Record a sent message**: + +```sql +INSERT INTO sent_messages + (post_id, recipient, subject, message, sent_at, strategy_id) +VALUES (?, ?, 'Stunning', ?, ?, ?); +``` + +**Get recent messages**: + +```sql +SELECT * FROM sent_messages +ORDER BY sent_at DESC +LIMIT ?; +``` + +**Update response status**: + +```sql +UPDATE sent_messages +SET got_response = 1, response_checked_at = ? +WHERE id = ?; +``` + +**Check if user was previously messaged**: + +```sql +SELECT COUNT(*) FROM sent_messages WHERE recipient = ?; +``` + +--- + +### Strategy Analytics + +**Get strategy statistics**: + +```sql +SELECT + strategy_id, + COUNT(*) as sent, + SUM(got_response) as responded +FROM sent_messages +WHERE strategy_id IS NOT NULL +GROUP BY strategy_id; +``` + +**Get response rate by subreddit**: + +```sql +SELECT + p.subreddit, + COUNT(*) as sent, + SUM(s.got_response) as responded +FROM sent_messages s +JOIN processed_posts p ON s.post_id = p.id +GROUP BY p.subreddit; +``` + +**Get daily response trend**: + +```sql +SELECT + DATE(sent_at, 'unixepoch') as date, + COUNT(*) as sent, + SUM(got_response) as responded +FROM sent_messages +GROUP BY DATE(sent_at, 'unixepoch') +ORDER BY date DESC +LIMIT 30; +``` + +--- + +### Few-Shot Learning + +**Get successful examples for a subreddit**: + +```sql +SELECT + original_message, + image_analysis, + post_title, + response_snippet +FROM successful_messages +WHERE subreddit = ? +ORDER BY created_at DESC +LIMIT 3; +``` + +**Store successful pair**: + +```sql +INSERT INTO successful_messages + (sent_message_id, original_message, image_analysis, + post_title, response_snippet, subreddit, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?); +``` + +--- + +## Repository Methods + +### PostsRepository + +```typescript +class PostsRepository { + /** Check if a post was already processed */ + getById(id: string): ProcessedPost | undefined + + /** Mark a post as processed */ + insert(post: ProcessedPostInsert): void + + /** Mark a post as having a message sent */ + markSent(id: string): void + + /** Update post with AI analysis results */ + updateAnalysis(id: string, analysis: string, message: string): void + + /** Get total count of processed posts */ + count(): number + + /** Get count by subreddit */ + countBySubreddit(subreddit: string): number +} +``` + +### MessagesRepository + +```typescript +class MessagesRepository { + /** Record a sent message */ + insert(message: MessageInsert): number + + /** Get recent sent messages */ + getRecent(limit: number): SentMessage[] + + /** Get messages by recipient */ + getByRecipient(recipient: string): SentMessage[] + + /** Update response status */ + markResponded(id: number, checkedAt: number): void + + /** Get strategy statistics */ + getStrategyStats(): StrategyStat[] + + /** Get response rate by subreddit */ + getSubredditStats(): SubredditStat[] + + /** Get daily response trend */ + getDailyTrend(days: number): DailyTrend[] + + /** Get total count of sent messages */ + count(): number + + /** Get count of responded messages */ + countResponded(): number +} +``` + +### SuccessfulMessagesRepository + +```typescript +class SuccessfulMessagesRepository { + /** Store a successful message/response pair */ + insert(pair: SuccessfulMessageInsert): void + + /** Get examples for few-shot learning */ + getExamples(subreddit: string, count: number): Example[] + + /** Get total count */ + count(): number + + /** Get recent examples */ + getRecent(limit: number): SuccessfulMessage[] +} +``` + +--- + +## Data Retention + +### Current Behavior + +All data is retained indefinitely. There is no automatic cleanup or retention policy. + +| Table | Retention | Notes | +|---|---|---| +| `processed_posts` | Indefinite | Grows with each post processed | +| `sent_messages` | Indefinite | Grows with each message sent | +| `successful_messages` | Indefinite | Grows with each response received | + +### Estimated Growth + +With default settings (20 messages/hour, 24/7 operation): + +| Timeframe | Posts | Messages | Responses (est. 30%) | +|---|---|---|---| +| 1 day | ~480 | ~288 | ~86 | +| 1 week | ~3,360 | ~2,016 | ~605 | +| 1 month | ~14,400 | ~8,640 | ~2,592 | +| 1 year | ~175,200 | ~105,120 | ~31,536 | + +### Recommended Cleanup (Future) + +Consider implementing automated cleanup: + +```sql +-- Archive old processed posts +CREATE TABLE processed_posts_archive AS +SELECT * FROM processed_posts +WHERE processed_at < ?; + +-- Delete archived posts +DELETE FROM processed_posts +WHERE processed_at < ?; + +-- Keep last N months of data +``` + +> **Note**: Successful messages should NEVER be deleted as they are used for few-shot learning. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..2d77a4e --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,413 @@ +# Deployment Guide + +Complete guide for deploying RedditBot in various environments. + +--- + +## Table of Contents + +1. [Prerequisites](#prerequisites) +2. [Local Development](#local-development) +3. [Docker Deployment](#docker-deployment) +4. [Docker Compose](#docker-compose) +5. [Dokploy Deployment](#dokploy-deployment) +6. [Production Considerations](#production-considerations) +7. [Monitoring & Maintenance](#monitoring--maintenance) +8. [Troubleshooting](#troubleshooting-1) + +--- + +## Prerequisites + +### Required + +| Requirement | Version | Notes | +|---|---|---| +| Node.js | 18+ | LTS recommended | +| npm | 9+ | Or yarn/pnpm | +| OpenRouter API Key | - | Sign up at [openrouter.ai](https://openrouter.ai) | +| Reddit Account | - | With DM permissions enabled | + +### Recommended + +| Requirement | Notes | +|---|---| +| 1 GB RAM | Minimum for browser automation | +| 1 CPU core | More for higher message throughput | +| 5 GB disk | For database, media, and browser data | + +--- + +## Local Development + +### 1. Clone and Install + +```bash +git clone +cd RedditBot +npm install +``` + +### 2. Configure + +Create `config.yaml`: + +```yaml +reddit: + subreddits: + - gonewild + pollInterval: 15 + targetGender: "F" + sellerKeywords: + - seller + - onlyfans + +openrouter: + apiKey: "sk-or-v1-your-key-here" + model: "meta-llama/llama-3.2-90b-vision-preview" + fallbackModels: + - "google/gemini-2.0-flash-001" + +rateLimit: + minDelayMs: 30000 + maxMessagesPerHour: 20 + jitterMs: 15000 + +browser: + headless: true + userDataDir: "./data/browser" + +database: + path: "./data/reddit-bot.db" + +web: + port: 3000 + +dryRun: true # Start in dry run mode +``` + +Set environment variables: + +```bash +export REDDIT_USERNAME="your-username" +export REDDIT_PASSWORD="your-password" +``` + +### 3. Initial Login + +Run the login script to establish a browser session: + +```bash +npm run login +``` + +This will: +1. Open a browser window +2. Navigate to Reddit login +3. Wait for you to complete login (including 2FA if enabled) +4. Save cookies to `data/browser/` + +### 4. Start the Bot + +```bash +# Development (with TypeScript execution) +npm run dev + +# Production (compiled JavaScript) +npm start +``` + +### 5. Access Dashboard + +Open http://localhost:3000 in your browser. + +--- + +## Docker Deployment + +### Build the Image + +```bash +docker build -t reddit-bot . +``` + +### Run the Container + +```bash +docker run -d \ + --name reddit-bot \ + -p 3000:3000 \ + -v $(pwd)/data:/app/data \ + -e OPENROUTER_API_KEY=sk-or-v1-your-key \ + -e REDDIT_USERNAME=your-username \ + -e REDDIT_PASSWORD=your-password \ + -e BROWSER_HEADLESS=true \ + reddit-bot:latest +``` + +### Dockerfile Structure + +```dockerfile +# Build stage +FROM node:18-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# Production stage +FROM node:18-alpine +WORKDIR /app +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/config.yaml ./config.yaml +COPY --from=builder /app/package.json ./package.json + +EXPOSE 3000 +CMD ["node", "dist/index.js"] +``` + +--- + +## Docker Compose + +### docker-compose.yml + +```yaml +version: '3.8' + +services: + reddit-bot: + build: . + container_name: reddit-bot + ports: + - "3000:3000" + volumes: + - ./data:/app/data + environment: + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} + - REDDIT_USERNAME=${REDDIT_USERNAME} + - REDDIT_PASSWORD=${REDDIT_PASSWORD} + - BROWSER_HEADLESS=true + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--spider", "http://localhost:3000/api/stats"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s +``` + +### Start with Compose + +```bash +# Set environment variables +cp .env.example .env +# Edit .env with your credentials + +# Build and start +docker compose up -d --build + +# View logs +docker compose logs -f reddit-bot + +# Stop +docker compose down + +# Restart +docker compose restart +``` + +--- + +## Dokploy Deployment + +Dokploy is a self-hosted platform for deploying applications. See `DOKPLOY_DEPLOY.md` for detailed instructions. + +### Quick Start + +1. **Create Application**: In Dokploy dashboard, create a new Docker application +2. **Set Environment Variables**: + - `OPENROUTER_API_KEY` + - `REDDIT_USERNAME` + - `REDDIT_PASSWORD` + - `BROWSER_HEADLESS` +3. **Mount Volume**: Mount `./data` for persistent storage +4. **Set Port**: Map container port 3000 to host port +5. **Deploy**: Push to repository and trigger deployment + +### Dokploy Configuration + +```yaml +# Dokploy-specific settings +app: + type: docker + repository: + branch: main + dockerfilePath: Dockerfile + + environment: + OPENROUTER_API_KEY: $OPENROUTER_API_KEY + REDDIT_USERNAME: $REDDIT_USERNAME + REDDIT_PASSWORD: $REDDIT_PASSWORD + BROWSER_HEADLESS: "true" + + ports: + - container: 3000 + host: 3000 + + volumes: + - source: reddit-bot-data + target: /app/data + type: bind + + restartPolicy: unless-stopped +``` + +--- + +## Production Considerations + +### Security + +1. **Network Isolation**: Run behind a reverse proxy with authentication +2. **Environment Variables**: Never commit credentials; use secrets management +3. **Dashboard Access**: Restrict to internal network or add auth +4. **Browser Data**: Protect `data/browser/` directory + +### Performance + +1. **Browser Headless**: Always use headless mode in production +2. **WAL Mode**: SQLite WAL mode is enabled by default for concurrent reads +3. **Rate Limiting**: Adjust based on Reddit account age and trust level +4. **Message Volume**: Start low (10-20/hour) and increase gradually + +### Reliability + +1. **Auto-restart**: Use `restart: unless-stopped` in Docker +2. **Health Checks**: Monitor `/api/stats` endpoint +3. **Logging**: Review logs regularly for errors +4. **Backups**: Regularly backup `data/reddit-bot.db` + +### Monitoring + +1. **Dashboard**: Check web dashboard for real-time stats +2. **Response Rate**: Monitor response rates per strategy/subreddit +3. **Error Rate**: Watch for AI refusals, send failures +4. **Browser Health**: Verify session isn't expired + +### Scaling + +RedditBot is designed as a single-instance application. For scaling: + +1. **Multiple Accounts**: Run separate instances per Reddit account +2. **Subreddit Split**: Distribute subreddits across instances +3. **Centralized DB**: Consider moving to PostgreSQL for multi-instance + +--- + +## Monitoring & Maintenance + +### Daily Checks + +- [ ] Dashboard shows bot is running +- [ ] Response rate is reasonable (>10%) +- [ ] No repeated errors in logs +- [ ] Browser session is active + +### Weekly Tasks + +- [ ] Review analytics (strategy performance, subreddit performance) +- [ ] Check disk usage (media files, database) +- [ ] Verify backup is working +- [ ] Review successful messages for quality + +### Monthly Tasks + +- [ ] Backup database +- [ ] Review and update strategy variants +- [ ] Check for OpenRouter model updates +- [ ] Review and update seller keywords +- [ ] Verify browser session is still valid + +### Backup Procedure + +```bash +# Stop the bot +docker compose stop + +# Backup database +cp data/reddit-bot.db data/reddit-bot.db.backup.$(date +%Y%m%d) + +# Backup browser session (optional) +tar czf data/browser.backup.$(date +%Y%m%d).tar.gz data/browser/ + +# Restart the bot +docker compose start +``` + +--- + +## Troubleshooting + +### Bot Won't Start + +**Symptom**: Bot exits immediately on startup. + +**Steps**: +1. Check logs: `docker compose logs reddit-bot` +2. Verify `config.yaml` syntax +3. Verify environment variables are set +4. Check for Zod validation errors in logs + +### Browser Login Fails + +**Symptom**: "Browser session expired" or login fails. + +**Steps**: +1. Delete browser data: `rm -rf data/browser/` +2. Re-run login: `npm run login` +3. Check Reddit account is not locked +4. Try `BROWSER_HEADLESS=false` for debugging + +### AI Analysis Fails + +**Symptom**: All messages show "AI refusal" or analysis errors. + +**Steps**: +1. Verify API key is valid and has balance +2. Check model availability on OpenRouter +3. Try changing to a different model +4. Review logs for specific error messages + +### High Error Rate + +**Symptom**: Many messages fail to send. + +**Steps**: +1. Check rate limiting (reduce maxMessagesPerHour) +2. Increase minDelayMs and jitterMs +3. Check for IP bans (try different network) +4. Review Reddit account standing + +### Dashboard Not Accessible + +**Symptom**: Cannot reach http://localhost:3000. + +**Steps**: +1. Verify bot is running (`docker compose ps`) +2. Check port is not in use: `lsof -i :3000` +3. Verify firewall allows port 3000 +4. Check web.port in config.yaml + +### Database Issues + +**Symptom**: Database errors or corruption. + +**Steps**: +1. Stop the bot +2. Backup the database +3. Try opening with SQLite viewer to check integrity +4. If corrupted, restore from backup +5. Consider enabling automatic backups diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..2a896d2 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,514 @@ +# Development Guide + +Guide for contributing to RedditBot development. + +--- + +## Table of Contents + +1. [Project Structure](#project-structure) +2. [Development Setup](#development-setup) +3. [Coding Standards](#coding-standards) +4. [Adding a New Service](#adding-a-new-service) +5. [Adding a New Strategy Variant](#adding-a-new-strategy-variant) +6. [Debugging](#debugging) +7. [Common Tasks](#common-tasks) +8. [Known Issues](#known-issues) + +--- + +## Project Structure + +``` +RedditBot/ +├── config.yaml # Main configuration +├── package.json # Dependencies and scripts +├── tsconfig.json # TypeScript config +├── Dockerfile # Container build +├── docker-compose.yml # Docker Compose +├── public/ +│ └── index.html # Web dashboard (single file) +├── scripts/ +│ ├── login.ts # Manual login helper +│ └── inspect-chat-dom.ts # DOM inspection debug script +└── src/ + ├── index.ts # Entry point / orchestrator + ├── config/ + │ └── index.ts # Config loader + Zod schema + ├── database/ + │ ├── client.ts # SQLite setup + migrations + │ └── repositories.ts # Repository classes + ├── reddit/ + │ └── api.ts # Reddit API client + ├── ai/ + │ └── openrouter.ts # OpenRouter AI client + ├── browser/ + │ ├── client.ts # Playwright browser manager + │ ├── message-page.ts # DM compose automation + │ └── media-downloader.ts # Media download + ├── services/ + │ ├── monitor.ts # Post discovery + │ ├── processor.ts # Processing pipeline + │ ├── response-checker.ts # Response monitoring + │ ├── strategy-selector.ts # A/B testing + │ ├── chat.ts # Chat scraping + messaging + │ ├── media.ts # Media management + │ ├── media-sync.ts # Media sync scheduler + │ └── auto-reply.ts # Auto-reply service + ├── utils/ + │ ├── logger.ts # Winston logger + │ ├── rate-limiter.ts # Rate limiting + │ ├── retry.ts # Retry wrapper + │ └── image.ts # Image utilities + └── web/ + └── server.ts # Express dashboard server +``` + +--- + +## Development Setup + +### 1. Install Dependencies + +```bash +npm install +``` + +### 2. Configure + +Copy and edit `config.yaml`: + +```bash +cp config.yaml config.yaml.backup # Backup existing +# Edit config.yaml with your settings +``` + +### 3. Set Environment Variables + +```bash +export REDDIT_USERNAME="your-username" +export REDDIT_PASSWORD="your-password" +export OPENROUTER_API_KEY="sk-or-v1-your-key" +``` + +### 4. Initial Login + +```bash +# Open browser for manual login +npm run login +``` + +### 5. Start Development Server + +```bash +# Development mode (hot reload with tsx) +npm run dev + +# Production mode (compiled JS) +npm run build && npm start +``` + +### 6. Access Dashboard + +Open http://localhost:3000 + +--- + +## Coding Standards + +### TypeScript + +- **Strict mode**: No `any` types (use `unknown` with type guards) +- **Interfaces**: Define interfaces for all data structures +- **Null checks**: Use optional chaining (`?.`) and nullish coalescing (`??`) +- **Async/Await**: Use async/await, not `.then()` chains + +```typescript +// Good +interface PostData { + id: string; + subreddit: string; + author: string; +} + +async function fetchPost(id: string): Promise { + const response = await api.get(`/posts/${id}`); + return response.data; +} + +// Bad +async function fetchPost(id: any): Promise { + return api.get(`/posts/${id}`).then(r => r.data); +} +``` + +### Logging + +Use `winston` logger, never `console.log`: + +```typescript +// Good +this.logger.info("Processed post {id}", { id: post.id }); +this.logger.warn("AI refusal detected", { postId: post.id }); +this.logger.error("Failed to send DM", { error: err.message }); + +// Bad +console.log("Processed post", post.id); +``` + +### Error Handling + +Use try/catch with structured error handling: + +```typescript +try { + await this.processPost(post); +} catch (err) { + this.logger.error("Post processing failed", { + postId: post.id, + error: err instanceof Error ? err.message : String(err) + }); + // Don't throw - allow other posts to be processed +} +``` + +### Service Structure + +Follow single responsibility: + +```typescript +class NewService { + constructor( + private config: Config, + private dependency: ExistingService, + private logger: Logger + ) {} + + async doSomething(input: string): Promise { + // Validate input + if (!input) { + throw new Error("Input is required"); + } + + // Do work + const result = await this.dependency.process(input); + + // Return result + return result; + } +} +``` + +### File Organization + +- One class per file (except repositories which share one file) +- Services in `src/services/` +- Utilities in `src/utils/` +- External integrations in their own folders (`src/ai/`, `src/reddit/`, `src/browser/`) + +--- + +## Adding a New Service + +### 1. Create the Service File + +```typescript +// src/services/new-service.ts +import { Config } from '../config'; +import { Logger } from '../utils/logger'; + +interface NewServiceDeps { + config: Config; + logger: Logger; + // other dependencies +} + +interface Result { + success: boolean; + data?: any; + error?: string; +} + +export class NewService { + constructor(private deps: NewServiceDeps) {} + + async execute(input: string): Promise { + const { config, logger } = this.deps; + + logger.info("NewService executing", { input }); + + // Implementation here + + return { success: true }; + } +} +``` + +### 2. Add to Dependencies Interface + +If the service needs other services, add them to the interface: + +```typescript +interface NewServiceDeps { + config: Config; + logger: Logger; + redditApi: RedditApi; + openrouter: OpenRouterService; +} +``` + +### 3. Wire Up in main() + +In `src/index.ts`, add initialization: + +```typescript +const newService = new NewService({ + config, + logger, + redditApi, + openrouter +}); + +// If it needs to run periodically: +setInterval(() => { + newService.execute(someInput).catch(err => { + logger.error("NewService error", { error: err.message }); + }); +}, intervalMs); +``` + +### 4. Add to Web Dashboard (Optional) + +If the service needs API endpoints, add them in `src/web/server.ts`: + +```typescript +server.app.get('/api/new-service', async (req, res) => { + try { + const result = await newService.execute(req.query.input as string); + res.json(result); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); +``` + +### 5. Update Config (If Needed) + +Add new configuration options to: +- `config.yaml` +- `src/config/index.ts` Zod schema +- Configuration documentation + +--- + +## Adding a New Strategy Variant + +### 1. Add to config.yaml + +```yaml +strategyVariants: + - name: "existing_variant" + prompt: "Existing prompt..." + enabled: true + + - name: "new_variant" + prompt: | + Write a friendly message that references the image content. + Be specific and genuine. Keep it under 2 sentences. + enabled: true +``` + +### 2. No Code Changes Needed + +The `StrategySelector` automatically picks up new variants from the config. The algorithm will: + +1. Include the new variant in random exploration +2. Track its response rate once messages are sent +3. Promote it to exploitation if it has good stats + +### 3. Monitor Performance + +Check performance via the dashboard or API: + +```bash +curl http://localhost:3000/api/analytics/by-strategy +``` + +--- + +## Debugging + +### Browser Debugging + +Run with visible browser: + +```yaml +# config.yaml +browser: + headless: false +``` + +Or set environment variable: + +```bash +BROWSER_HEADLESS=false npm run dev +``` + +### DOM Inspection + +Use the debug endpoint to inspect Reddit's shadow DOM: + +```bash +curl -X POST http://localhost:3000/api/debug/inspect-dom +``` + +Or run the standalone script: + +```bash +npx tsx scripts/inspect-chat-dom.ts +``` + +### Logging + +Adjust log level by modifying `src/utils/logger.ts`: + +```typescript +const logger = winston.createLogger({ + level: 'debug', // Change from 'info' to 'debug' + // ... +}); +``` + +### Database Inspection + +Open the database with SQLite viewer: + +```bash +# Install sqlite3 if needed +brew install sqlite3 + +# Open database +sqlite3 data/reddit-bot.db + +# Query +SELECT * FROM sent_messages LIMIT 5; +``` + +### Network Debugging + +Enable axios debug: + +```typescript +// In src/reddit/api.ts +import axios from 'axios'; +axios.defaults.debug = true; +``` + +--- + +## Common Tasks + +### Add a New Subreddit + +```yaml +# config.yaml +reddit: + subreddits: + - gonewild + - ratemyboobs + - new_subreddit_name # Add here +``` + +### Change AI Model + +```yaml +# config.yaml +openrouter: + model: "new-model-name" + fallbackModels: + - "fallback-model-1" + - "fallback-model-2" +``` + +### Adjust Rate Limits + +```yaml +# config.yaml +rateLimit: + minDelayMs: 45000 # Increase delay + maxMessagesPerHour: 15 # Reduce cap + jitterMs: 20000 # More jitter +``` + +### Enable Dry Run + +```yaml +# config.yaml +dryRun: true # Logs all actions without sending +``` + +Or via dashboard: `PUT /api/config` with `{"dryRun": true}` + +### Add Database Column + +```typescript +// In src/database/client.ts +function runMigrations(db: Database) { + // ... existing migrations ... + + // Check if column exists + const tableInfo = db.prepare(`PRAGMA table_info(sent_messages)`).all(); + const hasNewColumn = tableInfo.some((col: any) => col.name === 'new_column'); + + if (!hasNewColumn) { + db.exec("ALTER TABLE sent_messages ADD COLUMN new_column TEXT"); + } +} +``` + +### Add New API Endpoint + +```typescript +// In src/web/server.ts +import { Request, Response } from 'express'; + +server.app.get('/api/new-endpoint', async (req: Request, res: Response) => { + try { + const result = await someService.doSomething(); + res.json({ success: true, data: result }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); +``` + +--- + +## Known Issues + +### Hardcoded Username in ChatService + +`ChatService` has `myUsername = 'Upset_Marsupial1006'` hardcoded in JavaScript evaluation scripts. This should be parameterized from config. + +**Fix needed**: Pass username to `page.evaluate()` calls. + +### Shadow DOM Fragility + +Reddit frequently updates their `faceplate-*` components. Shadow DOM traversal code may break without warning. + +**Mitigation**: Use `scripts/inspect-chat-dom.ts` to debug when selectors change. + +### No Test Coverage + +The project has no automated tests. Any changes should be manually tested. + +**Recommendation**: Add tests for critical paths: +- StrategySelector algorithm +- ChatService shadow DOM parsing +- OpenRouterService model fallback + +### Single-File Dashboard + +The entire dashboard is in `public/index.html` (~2000+ lines). Making changes requires editing a single large file. + +**Recommendation**: Consider migrating to a proper frontend framework for maintainability. diff --git a/docs/services.md b/docs/services.md new file mode 100644 index 0000000..7d23a9f --- /dev/null +++ b/docs/services.md @@ -0,0 +1,854 @@ +# 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 +} +``` + +### 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 +} +``` + +### 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 +} +``` + +### 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 + + /** Load full conversation history */ + loadConversation(chatId: string): Promise + + /** Send a message in a conversation */ + sendMessage(chatId: string, text: string): Promise + + /** Get cached conversations */ + getCachedConversations(): Conversation[] + + /** Check for new messages in conversations */ + checkForUpdates(): Promise + + /** 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 + + /** 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 + + /** Trigger full sync */ + syncFull(): Promise + + /** 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 +} +``` + +### 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 + + /** Check if a user is a seller/spam */ + checkUserProfile(username: string): Promise + + /** Make authenticated request using browser cookies */ + authenticatedRequest(url: string): Promise +} +``` + +### 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 + + /** Quick gender detection (cost-saving) */ + detectGenderOnly(imageBase64: string): Promise + + /** Generate DM message */ + generateMessage( + imageAnalysis: ImageAnalysis, + postTitle: string, + strategy: string + ): Promise + + /** Generate chat reply */ + generateReply(conversationHistory: Message[]): Promise + + /** 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 + + /** Verify browser session is active */ + verifySession(): Promise + + /** Perform automated login */ + login(): Promise + + /** Get page context for operations */ + getPage(): Promise + + /** Close browser */ + close(): Promise +} +``` + +### 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 + + /** Check if recipient field shows an error */ + hasRecipientError(page: Page): Promise +} +``` + +### 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 + + /** 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 diff --git a/package-lock.json b/package-lock.json index 32c2c9d..bba4f71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,8 @@ "@types/express": "^5.0.6", "@types/node": "^25.0.3", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.1.7" } }, "node_modules/@colors/colors": { @@ -49,6 +50,40 @@ "kuler": "^2.0.0" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -491,6 +526,306 @@ "node": ">=18" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@so-ric/colorspace": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", @@ -501,6 +836,24 @@ "text-hex": "1.0.x" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -522,6 +875,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -532,6 +896,20 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", @@ -615,6 +993,119 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -653,6 +1144,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -816,6 +1317,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -902,6 +1413,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -1062,6 +1580,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1137,6 +1662,16 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1161,6 +1696,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -1229,6 +1774,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -1571,6 +2134,267 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/logform": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", @@ -1588,6 +2412,16 @@ "node": ">= 12.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1672,6 +2506,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -1711,6 +2564,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1824,6 +2688,33 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/playwright": { "version": "1.57.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", @@ -1854,6 +2745,35 @@ "node": ">=18" } }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -1987,6 +2907,40 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rolldown": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2198,6 +3152,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -2243,6 +3204,16 @@ "simple-concat": "^1.0.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -2252,6 +3223,13 @@ "node": "*" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2261,6 +3239,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -2313,6 +3298,50 @@ "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2331,6 +3360,14 @@ "node": ">= 14.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -2462,6 +3499,206 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.1", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/winston": { "version": "3.19.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", diff --git a/package.json b/package.json index b853e4b..fe19782 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,10 @@ "build": "tsc", "start": "tsx src/index.ts", "login": "tsx scripts/login.ts", - "dev": "tsx watch src/index.ts" + "dev": "tsx watch src/index.ts", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "keywords": [ "reddit", @@ -35,6 +38,7 @@ "@types/express": "^5.0.6", "@types/node": "^25.0.3", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.1.7" } } diff --git a/public/index.html b/public/index.html index 7a5c589..ccce0db 100644 --- a/public/index.html +++ b/public/index.html @@ -4,182 +4,527 @@ - Reddit Bot Dashboard + Reddit Bot · Console + + + -
-
-

Reddit Bot Dashboard

-
- - Stopped - - - -
-
- -
-
-

Messages Sent

-
0
-
0 / 8 this hour
-
-
-

Responses

-
0
-
0% response rate
-
-
-

Posts Processed

-
0
-
Total analyzed
-
-
-

Success Examples

-
0
-
Learning from responses
+
+
+
r
+
+
Reddit Bot
+
a console for warm conversations
+
+ + Stopped + +
+ +
+ + + +
+
- -
-
-
⏳ Active Timers
-
-
+
- -
-
-
- 🚀 Next Message + + + +
+
+ 01 + At a glance + +
+
+
+

Messages sent

+
0
+
0 / 8 this hour
+
+
+

Responses

+
0
+
0% response rate
+
+
+

Posts processed

+
0
+
Total analyzed
+
+
+

Success examples

+
0
+
Learning from responses
+
+
+
+ + +
+
+ 02 + Operating window + +
+
+
+
Active timers
+
+
+ +
+
+
+ 🚀 Next message +
+
Ready
-
Ready
-
-
-
-
-
Waiting for new contents...
-
- - -
-
-
- ⏰ Hourly Limit Reset +
+
-
60:00
+
Waiting for new contents…
-
-
-
-
- 0 messages remaining this hour -
-
+
+
+
+ ⏰ Hourly limit reset +
+
60:00
+
+
+
+
+
+ 0 messages remaining this hour +
+
+ +
-
+ -
-
-
Recent Activity
- -
-
-
- Active Chats (Last 48 Hours) - - -
-
-
Loading chats...
+
+
+
+ Cached conversations · browsable archive + + +
+
+
Loading cached conversations…
+
-
+ -
-
-
- 📁 Cached Conversations (Browsable) - -
-
-
Loading cached conversations...
+ +
+
+ 05 + Media gallery + +
+
+
+
+ Collected media + Sync status… + + + + + +
+
+
No media cached yet
+
-
+ - + +
+
+ 06 + Console + +
+
+
+
+ Realtime log +
+ + Connecting… +
+ +
+
+
Waiting for log data…
+
+
+
+
+ + +
+
+ 07 + Configuration snapshot + +
+
+
+
+

Monitoring subreddits

+
+
+
+

Target genders

+
+
+
+
+
+ + -
-
-
-

Monitoring Subreddits

-
-
-
-

Target Genders

-
-
-
-
- - -
-
-
- 📋 Realtime Log -
- - Connecting... -
- -
-
-
Waiting for log data...
-
-
-
- - -
-
-
- Media Gallery - - - - -
-
-
No media cached yet
-
-
-
- - -