From e8b730b592991372b32e4f7b075fd0bbe9b5d00a Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sat, 8 Aug 2026 22:08:14 +0100 Subject: [PATCH] feat: add agent API and operational analytics --- DOKPLOY_DEPLOY.md | 9 + .../features/performance/performance-api.ts | 9 + .../features/performance/performance-page.tsx | 37 +- .../performance/performance-queries.ts | 8 + .../src/features/performance/performance.css | 7 +- docker-compose.yml | 2 + docs/api.md | 1117 ++--------------- docs/configuration.md | 12 +- docs/deployment.md | 8 +- package-lock.json | 297 ++++- package.json | 4 + packages/api-contracts/src/chats.ts | 6 +- packages/api-contracts/src/messages.ts | 5 + packages/api-contracts/src/stats.ts | 17 + .../api-contracts/tests/contracts.test.ts | 10 + scripts/generate-api-docs.ts | 76 ++ src/bootstrap.ts | 4 +- src/config/index.ts | 24 +- src/config/persistence.ts | 20 +- src/database/client.ts | 16 + src/database/repositories.ts | 220 +++- src/main-loop.ts | 75 +- src/mcp/api-client.ts | 75 ++ src/mcp/server.ts | 172 +++ src/reddit/api.ts | 73 +- src/reddit/request-coordinator.ts | 56 + src/services/chat/service.ts | 46 +- src/services/conversation-outcome-recorder.ts | 46 + src/services/conversation-outcome.ts | 14 +- src/services/monitor.ts | 4 +- src/services/processor.ts | 15 + .../reddit-account-runtime-builder.ts | 64 +- src/services/reddit-account-runtime.ts | 21 + src/services/response-checker.ts | 39 +- src/services/strategy-selector.ts | 46 +- src/web/agent-api-contracts.ts | 134 ++ src/web/agent-job-manager.ts | 65 + src/web/api-catalog.ts | 177 +++ src/web/auth.ts | 17 +- src/web/bot-control.ts | 62 + src/web/log-broadcaster.ts | 8 +- src/web/routes/agent.ts | 229 ++++ src/web/routes/analytics.ts | 12 +- src/web/routes/control.ts | 22 +- src/web/server.ts | 8 +- tests/agent-job-manager.test.ts | 22 + tests/api-catalog.test.ts | 52 + .../chat-service-progressive-backfill.test.ts | 10 +- tests/config-persistence.test.ts | 4 + tests/config.test.ts | 3 +- tests/conversation-outcome-recorder.test.ts | 57 + tests/conversation-outcome.test.ts | 9 +- .../dashboard/performance-operations.test.tsx | 15 + tests/database.test.ts | 2 +- tests/mcp-api-client.test.ts | 29 + tests/mcp-server.test.ts | 47 + tests/monitor.test.ts | 23 +- tests/reddit-api.test.ts | 18 + tests/reddit-request-coordinator.test.ts | 58 + tests/repositories.test.ts | 33 + tests/response-checker.test.ts | 11 +- tests/testUtils.ts | 3 + tests/web-auth-cutover.test.ts | 57 +- tests/web-auth.test.ts | 22 + tests/web/control-realtime.test.ts | 1 + 65 files changed, 2650 insertions(+), 1214 deletions(-) create mode 100644 scripts/generate-api-docs.ts create mode 100644 src/mcp/api-client.ts create mode 100644 src/mcp/server.ts create mode 100644 src/reddit/request-coordinator.ts create mode 100644 src/services/conversation-outcome-recorder.ts create mode 100644 src/web/agent-api-contracts.ts create mode 100644 src/web/agent-job-manager.ts create mode 100644 src/web/api-catalog.ts create mode 100644 src/web/bot-control.ts create mode 100644 src/web/routes/agent.ts create mode 100644 tests/agent-job-manager.test.ts create mode 100644 tests/api-catalog.test.ts create mode 100644 tests/conversation-outcome-recorder.test.ts create mode 100644 tests/mcp-api-client.test.ts create mode 100644 tests/mcp-server.test.ts create mode 100644 tests/reddit-request-coordinator.test.ts diff --git a/DOKPLOY_DEPLOY.md b/DOKPLOY_DEPLOY.md index 98cc45f..9caf6f2 100644 --- a/DOKPLOY_DEPLOY.md +++ b/DOKPLOY_DEPLOY.md @@ -9,6 +9,15 @@ Configure only the application secrets required by the Compose file: - `OPENROUTER_API_KEY` - `WEB_DASHBOARD_TOKEN` +Optional agent credentials: + +- `REDDIT_BOT_AGENT_READ_TOKEN` permits authenticated GET/SSE access but cannot mutate state. +- `REDDIT_BOT_AGENT_CONTROL_TOKEN` additionally permits versioned control and sync-job operations. + +Use separate long random values. Neither agent token can invoke legacy send, +session-import, account-activation, or configuration mutations. The dashboard +token remains the administrator credential. + Do not configure Reddit usernames, passwords, display servers, CDP endpoints, or remote-browser ports. ## Account activation diff --git a/apps/dashboard/src/features/performance/performance-api.ts b/apps/dashboard/src/features/performance/performance-api.ts index 22b698b..4c9aaca 100644 --- a/apps/dashboard/src/features/performance/performance-api.ts +++ b/apps/dashboard/src/features/performance/performance-api.ts @@ -3,6 +3,7 @@ import { StrategyPerformanceResponseSchema, SubredditPerformanceResponseSchema, SuccessfulExamplesResponseSchema, + FunnelResponseSchema, } from '../../../../../packages/api-contracts/src/index.js'; import type { z } from 'zod'; import { createDashboardRequest } from '../../api/request.js'; @@ -11,6 +12,7 @@ type SubredditPerformanceResponse = z.infer; type DailyTrendResponse = z.infer; type SuccessfulExamplesResponse = z.infer; +type FunnelResponse = z.infer; async function parseResponse( response: Response, @@ -35,6 +37,7 @@ export interface PerformanceApi { getStrategyPerformance(): Promise; getDailyTrend(days: number): Promise; getSuccessfulExamples(): Promise; + getFunnel(): Promise; } export function createPerformanceApi( @@ -66,6 +69,12 @@ export function createPerformanceApi( SuccessfulExamplesResponseSchema, ); }, + async getFunnel() { + return parseResponse( + await request('/api/analytics/funnel'), + FunnelResponseSchema, + ); + }, } satisfies PerformanceApi; } diff --git a/apps/dashboard/src/features/performance/performance-page.tsx b/apps/dashboard/src/features/performance/performance-page.tsx index b178084..8273a42 100644 --- a/apps/dashboard/src/features/performance/performance-page.tsx +++ b/apps/dashboard/src/features/performance/performance-page.tsx @@ -4,12 +4,14 @@ import type { StrategyPerformanceResponseSchema, SubredditPerformanceResponseSchema, SuccessfulExample, + FunnelResponseSchema, } from '../../../../../packages/api-contracts/src/index.js'; import { useDailyTrendQuery, useStrategyPerformanceQuery, useSubredditPerformanceQuery, useSuccessfulExamplesQuery, + useFunnelQuery, } from './performance-queries.js'; import './performance.css'; @@ -17,6 +19,7 @@ const LOW_CONFIDENCE_SAMPLE_SIZE = 20; type SubredditPerformance = z.infer[number]; type StrategyPerformance = z.infer[number]; type DailyTrend = z.infer[number]; +type Funnel = z.infer; function SampleSize({ totalSent }: { totalSent: number }) { const isLowConfidence = totalSent < LOW_CONFIDENCE_SAMPLE_SIZE; @@ -28,6 +31,30 @@ function SampleSize({ totalSent }: { totalSent: number }) { ); } +function FunnelPanel({ funnel }: { funnel: Funnel }) { + const stages = [ + ['Processed', funnel.processed], + ['Sent', funnel.sent], + [`Mature (${funnel.responseWindowHours}h)`, funnel.mature], + ['Responded', funnel.responded], + ['Engaged', funnel.engaged], + ['Images', funnel.images], + ] as const; + return ( +
+
Qualified engagement

Outcome funnel

{funnel.pending} awaiting maturity
+
    + {stages.map(([label, value]) =>
  1. {label}{value}
  2. )} +
+
+ Declined {funnel.declined} + Boundary {funnel.boundary} + Upsell {funnel.upsell} +
+
+ ); +} + function PerformanceTable({ label, rows, @@ -40,7 +67,7 @@ function PerformanceTable({ return (
- + {rows.map((row) => ( @@ -49,10 +76,10 @@ function PerformanceTable({ {'configured' in row && row.configured ? Configured : null} - + - + ))} @@ -101,7 +128,8 @@ export function PerformancePage() { const strategies = useStrategyPerformanceQuery(); const trend = useDailyTrendQuery(30); const examples = useSuccessfulExamplesQuery(); - const queries = [subreddits, strategies, trend, examples]; + const funnel = useFunnelQuery(); + const queries = [subreddits, strategies, trend, examples, funnel]; if (queries.some((query) => query.isPending)) { return
Loading performance…
; } @@ -113,6 +141,7 @@ export function PerformancePage() { return (
Decision support

Performance

Compare response and received-image outcomes without hiding the sample sizes behind the percentages.

+ {funnel.data ? : null}
Last 30 days

Response trend

By subreddit

'subreddit' in row ? row.subreddit : row.strategyId} />
diff --git a/apps/dashboard/src/features/performance/performance-queries.ts b/apps/dashboard/src/features/performance/performance-queries.ts index 8860ab9..376bcf5 100644 --- a/apps/dashboard/src/features/performance/performance-queries.ts +++ b/apps/dashboard/src/features/performance/performance-queries.ts @@ -6,6 +6,7 @@ export const performanceQueryKeys = { strategies: ['performance', 'strategies'] as const, trend: (days: number) => ['performance', 'trend', days] as const, successfulExamples: ['performance', 'successful-examples'] as const, + funnel: ['performance', 'funnel'] as const, }; export function useSubredditPerformanceQuery() { @@ -35,3 +36,10 @@ export function useSuccessfulExamplesQuery() { queryFn: performanceApi.getSuccessfulExamples, }); } + +export function useFunnelQuery() { + return useQuery({ + queryKey: performanceQueryKeys.funnel, + queryFn: performanceApi.getFunnel, + }); +} diff --git a/apps/dashboard/src/features/performance/performance.css b/apps/dashboard/src/features/performance/performance.css index 2222a1c..c1b00c0 100644 --- a/apps/dashboard/src/features/performance/performance.css +++ b/apps/dashboard/src/features/performance/performance.css @@ -9,6 +9,11 @@ .configured-label, .confidence-warning { display: block; margin-top: .2rem; color: var(--accent); font-size: .68rem; font-weight: 750; } .confidence-warning { color: var(--warning); } .sample-size { white-space: nowrap; color: var(--muted); } +.interval { display: block; margin-top: .2rem; color: var(--muted); white-space: nowrap; } +.funnel-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .6rem; padding: 0; margin: 0; list-style: none; } +.funnel-list li { display: flex; justify-content: space-between; gap: 1rem; padding: .8rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; } +.guardrail-list { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: .7rem; color: var(--muted); } +.guardrail-list span { padding: .45rem .65rem; border: 1px solid var(--border); border-radius: 999px; } .trend-list { display: grid; gap: .65rem; padding: 0; margin: 0; list-style: none; } .trend-list li { display: grid; grid-template-columns: 6.5rem minmax(5rem, 1fr) 3rem 8rem 6rem; align-items: center; gap: .75rem; } .trend-track { height: .5rem; overflow: hidden; background: var(--border); border-radius: 999px; } @@ -21,4 +26,4 @@ .successful-example blockquote { margin: 1rem 0; padding-left: 1rem; border-left: 2px solid var(--accent); } .successful-example p { margin-bottom: 0; color: var(--muted); } .feature-empty { color: var(--muted); } -@media (min-width: 64rem) { .performance-grid, .example-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (min-width: 64rem) { .performance-grid, .example-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .funnel-list { grid-template-columns: repeat(6, minmax(0, 1fr)); } } diff --git a/docker-compose.yml b/docker-compose.yml index c8517ea..cb469e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ services: environment: - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} - WEB_DASHBOARD_TOKEN=${WEB_DASHBOARD_TOKEN} + - REDDIT_BOT_AGENT_READ_TOKEN=${REDDIT_BOT_AGENT_READ_TOKEN:-} + - REDDIT_BOT_AGENT_CONTROL_TOKEN=${REDDIT_BOT_AGENT_CONTROL_TOKEN:-} volumes: reddit_bot_data: diff --git a/docs/api.md b/docs/api.md index 9096147..9cec8a9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,1019 +1,106 @@ -# API Reference +# RedditBot API -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**: Required for cutover; not yet enforced by the checked-in Express host - ---- +This file is generated from `src/web/api-catalog.ts`. Run `npm run docs:api` +after changing the HTTP surface. The live OpenAPI 3.1 document is available at +`GET /api/openapi.json`, and compact agent discovery is available at +`GET /api/v1/capabilities`. ## Authentication -The React cutover requires a single Express authentication boundary covering -the dashboard assets, `/api`, both SSE streams, debug routes, and private media. -That boundary and its token/session configuration are not present in the -checked-in server yet. Until they land and their security tests pass, bind the -dashboard to a trusted network and do not expose port 3000 publicly. - -The intended contract is an HttpOnly, SameSite=Strict dashboard session for -interactive users and bearer-token access for API clients. Treat those details -as a migration requirement, not as proof of current protection. - ---- - -## 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": { - "sessionDir": "./data/browser-session", - "dryRun": false - }, - "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 - -`GET /api/events/stream` is the typed resumable event stream used by the React -dashboard. Each SSE frame contains `id`, `event`, and `data` fields. The data is -the complete validated event envelope: - -```json -{ - "id": "1786201200000-000000", - "occurredAt": "2026-08-08T15:00:00.000Z", - "type": "log.created", - "payload": { - "level": "info", - "message": "Dashboard started" - } -} -``` - -Clients may reconnect with `Last-Event-ID`; the server replays retained events -after that ID in order. The stream sends heartbeats, bounds buffered events and -slow-client queues, and uses the same authentication boundary as the API. - -Current event families include bot status, message sent or failed, -conversation updated, reply received, sync lifecycle and progress, rate-limit -state, Reddit session state, and logs. - -`GET /api/logs/stream` remains temporarily available for compatibility tooling. -New clients use the typed `/api/events/stream` endpoint. - -**Log Levels**: - -| Level | Description | -|---|---| -| `info` | Normal operation | -| `warn` | Warning (recoverable issue) | -| `error` | Error (action failed) | -| `debug` | Debug information | +The dashboard token is an administrator credential. Browser users can exchange +it for an HttpOnly, SameSite=Strict session; API clients can send it as a bearer +token. + +Optional least-privilege bearer credentials are configured with: + +- `REDDIT_BOT_AGENT_READ_TOKEN`: GET, HEAD, SSE, and private-media reads except debug routes. +- `REDDIT_BOT_AGENT_CONTROL_TOKEN`: read access plus mutations under `/api/v1`. + +Control credentials cannot send messages, import sessions, activate accounts, +or update legacy configuration endpoints. Those operations require the +administrator dashboard token. + +## Agent entry points + +- `GET /healthz`: unauthenticated process liveness with no private state. +- `GET /api/v1/readyz`: component readiness and authentication degradation. +- `GET /api/v1/overview`: bot, account, sync, rate-limit, and recent-error snapshot. +- `GET /api/v1/control`: current running state. +- `POST /api/v1/control/start`, `/stop`, or `POST /api/v1/control`: explicit bot control. +- `POST /api/v1/jobs`: tracked chat/media sync; poll `GET /api/v1/jobs/:id`. +- `GET /api/events/stream`: replayable typed SSE events. + +## MCP server + +Run the stdio MCP adapter with `npm run mcp`. It connects to +`REDDIT_BOT_API_URL` (default `http://127.0.0.1:3000`) and reads its bearer +credential from `REDDIT_BOT_AGENT_TOKEN`, then the scoped agent variables, and +finally `WEB_DASHBOARD_TOKEN`. + +Read tools cover overview, readiness, accounts, activity, conversations, +errors, sync state, and performance. Control tools include `start_bot`, +`stop_bot`, `set_bot_running`, `trigger_sync`, and `get_job`. External +message tools are separately annotated as destructive/non-idempotent and need +an administrator credential. + +## Operations + +| Method | Path | Summary | Access | Mutates | +|---|---|---|---|---| +| `GET` | `/healthz` | Process liveness | read | no | +| `GET` | `/api/v1/overview` | Consolidated operational snapshot | read | no | +| `GET` | `/api/v1/readyz` | Component readiness | read | no | +| `GET` | `/api/v1/capabilities` | Discover API operations and safety attributes | read | no | +| `GET` | `/api/openapi.json` | OpenAPI 3.1 description | read | no | +| `GET` | `/api/v1/control` | Get bot running state | read | no | +| `POST` | `/api/v1/control` | Set bot running state | control | yes | +| `POST` | `/api/v1/control/start` | Start bot processing | control | yes | +| `POST` | `/api/v1/control/stop` | Stop bot processing | control | yes | +| `GET` | `/api/v1/jobs` | List recent asynchronous jobs | read | no | +| `GET` | `/api/v1/jobs/{id}` | Get asynchronous job state | read | no | +| `POST` | `/api/v1/jobs` | Start chat or media synchronisation | control | yes | +| `GET` | `/api/stats` | Get bot statistics | read | no | +| `GET` | `/api/activity` | Get recent activity | read | no | +| `GET` | `/api/posts/by-author/{username}` | Get processed posts by author | read | no | +| `GET` | `/api/messages` | Get recent sent messages | read | no | +| `GET` | `/api/successful` | Get successful message examples | read | no | +| `GET` | `/api/chats` | Get active chats | read | no | +| `GET` | `/api/chats/sync` | Get chat sync state | read | no | +| `POST` | `/api/chats/sync/delta` | Trigger incremental chat sync | admin | yes | +| `POST` | `/api/chats/sync/full` | Trigger full chat sync | admin | yes | +| `POST` | `/api/chats/fetch-all` | Fetch all chats synchronously | admin | yes | +| `GET` | `/api/conversations` | List conversations | read | no | +| `GET` | `/api/conversations/{id}` | Get conversation detail | read | no | +| `POST` | `/api/conversations/{id}/suggest-reply` | Generate a reply suggestion | admin | yes | +| `POST` | `/api/conversations/{id}/suggest-media-ask` | Generate a media request suggestion | admin | yes | +| `POST` | `/api/conversations/{id}/send-reply` | Send a conversation reply | admin | yes | +| `POST` | `/api/conversations/start` | Start a new conversation | admin | yes | +| `GET` | `/api/media` | List indexed private media | read | no | +| `GET` | `/api/media/sync` | Get media sync state | read | no | +| `POST` | `/api/media/sync` | Trigger media sync | admin | yes | +| `POST` | `/api/media/sync/full` | Trigger full media sync | admin | yes | +| `POST` | `/api/start` | Start bot processing (legacy) | admin | yes | +| `POST` | `/api/stop` | Stop bot processing (legacy) | admin | yes | +| `GET` | `/api/timers` | Get rate-limit timers | read | no | +| `GET` | `/api/analytics/by-subreddit` | Get performance by subreddit | read | no | +| `GET` | `/api/analytics/by-strategy` | Get performance by strategy | read | no | +| `GET` | `/api/analytics/funnel` | Get mature message outcome funnel | read | no | +| `GET` | `/api/analytics/trend` | Get daily performance trend | read | no | +| `GET` | `/api/config` | Get non-secret application configuration | read | no | +| `PUT` | `/api/config` | Update application configuration | admin | yes | +| `GET` | `/api/login/status` | Get Reddit session state | read | no | +| `POST` | `/api/login/session-import` | Import Reddit and Matrix credentials | admin | yes | +| `GET` | `/api/accounts` | List Reddit accounts | read | no | +| `POST` | `/api/accounts/{accountId}/activate` | Activate a Reddit account | admin | yes | +| `GET` | `/api/logs/stream` | Stream legacy log events | read | no | +| `GET` | `/api/events/stream` | Stream typed application events | read | no | +| `GET` | `/api/logs` | Get recent structured logs | read | no | +| `GET` | `/api/debug/cookies` | Get redacted cookie diagnostics when enabled | admin | no | +| `POST` | `/api/debug/check-responses` | Run response check when enabled | admin | yes | + +## Error model + +Errors use JSON with an `error` string and optional `detail`. Authentication +failures are `401`; recognized but insufficient agent credentials receive +`403`; invalid inputs receive `400`; unavailable services receive `503`. +Long-running agent jobs expose failure text in the job resource. diff --git a/docs/configuration.md b/docs/configuration.md index 49af2ea..97832a3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -42,7 +42,7 @@ Configuration is loaded at startup by `loadConfig()` in `src/config/index.ts`, w 3. Validates the entire configuration tree using Zod 4. Returns a strongly-typed `Config` object -Application-owned settings are edited from the dashboard and saved to SQLite. `OPENROUTER_API_KEY`, `WEB_DASHBOARD_TOKEN`, and `DATABASE_PATH` remain environment-owned bootstrap values and are never stored in `app_config`. +Application-owned settings are edited from the dashboard and saved to SQLite. `OPENROUTER_API_KEY`, `WEB_DASHBOARD_TOKEN`, `REDDIT_BOT_AGENT_READ_TOKEN`, `REDDIT_BOT_AGENT_CONTROL_TOKEN`, and `DATABASE_PATH` remain environment-owned bootstrap values and are never stored in `app_config`. --- @@ -87,6 +87,10 @@ Environment variables provide secrets and bootstrap settings that are intentiona |---|---|---|---|---| | `OPENROUTER_API_KEY` | Yes | string | OpenRouter API key for AI services | `sk-or-v1-abc123...` | | `WEB_DASHBOARD_TOKEN` | Yes when dashboard enabled | string | Dashboard access token | `long-random-secret` | +| `REDDIT_BOT_AGENT_READ_TOKEN` | No | string | Least-privilege GET/SSE agent access | `separate-read-secret` | +| `REDDIT_BOT_AGENT_CONTROL_TOKEN` | No | string | Read access plus `/api/v1` controls/jobs | `separate-control-secret` | +| `REDDIT_BOT_API_URL` | MCP only | URL | HTTP base URL used by `npm run mcp` | `http://127.0.0.1:3000` | +| `REDDIT_BOT_AGENT_TOKEN` | MCP only | string | Token selected for the MCP process | `separate-read-secret` | | `DATABASE_PATH` | No | string | SQLite database location | `./data/reddit-bot.db` | ### Setting Environment Variables @@ -98,6 +102,8 @@ Create a `.env` file in the project root: ```env OPENROUTER_API_KEY=sk-or-v1-abc123... WEB_DASHBOARD_TOKEN=long-random-secret +REDDIT_BOT_AGENT_READ_TOKEN=separate-read-secret +REDDIT_BOT_AGENT_CONTROL_TOKEN=separate-control-secret ``` > **Note**: Add `.env` to `.gitignore` - never commit credentials. @@ -107,6 +113,8 @@ WEB_DASHBOARD_TOKEN=long-random-secret ```bash export OPENROUTER_API_KEY=sk-or-v1-abc123... export WEB_DASHBOARD_TOKEN=long-random-secret +export REDDIT_BOT_AGENT_READ_TOKEN=separate-read-secret +export REDDIT_BOT_AGENT_CONTROL_TOKEN=separate-control-secret ``` **Option 3: Docker** @@ -117,6 +125,8 @@ Pass via `docker-compose.yml` or Docker CLI: environment: - OPENROUTER_API_KEY=sk-or-v1-abc123... - WEB_DASHBOARD_TOKEN=long-random-secret + - REDDIT_BOT_AGENT_READ_TOKEN=separate-read-secret + - REDDIT_BOT_AGENT_CONTROL_TOKEN=separate-control-secret ``` --- diff --git a/docs/deployment.md b/docs/deployment.md index cbdd35a..f970796 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -91,6 +91,8 @@ Set environment variables: ```bash export OPENROUTER_API_KEY="sk-or-v1-your-key" export WEB_DASHBOARD_TOKEN="long-random-secret" +export REDDIT_BOT_AGENT_READ_TOKEN="separate-read-secret" +export REDDIT_BOT_AGENT_CONTROL_TOKEN="separate-control-secret" ``` ### 3. Import account session @@ -135,6 +137,8 @@ docker run -d \ -v $(pwd)/data:/app/data \ -e OPENROUTER_API_KEY=sk-or-v1-your-key \ -e WEB_DASHBOARD_TOKEN=long-random-secret \ + -e REDDIT_BOT_AGENT_READ_TOKEN=separate-read-secret \ + -e REDDIT_BOT_AGENT_CONTROL_TOKEN=separate-control-secret \ reddit-bot:latest ``` @@ -169,9 +173,11 @@ services: environment: - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} - WEB_DASHBOARD_TOKEN=${WEB_DASHBOARD_TOKEN} + - REDDIT_BOT_AGENT_READ_TOKEN=${REDDIT_BOT_AGENT_READ_TOKEN:-} + - REDDIT_BOT_AGENT_CONTROL_TOKEN=${REDDIT_BOT_AGENT_CONTROL_TOKEN:-} restart: unless-stopped healthcheck: - test: ["CMD", "wget", "--spider", "http://localhost:3000/api/stats"] + test: ["CMD", "wget", "--spider", "http://localhost:3000/healthz"] interval: 30s timeout: 10s retries: 3 diff --git a/package-lock.json b/package-lock.json index d7dd06e..1f87c07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", "@tanstack/react-query": "^5.90.20", "axios": "^1.13.2", "better-sqlite3": "^13.0.3", @@ -1060,6 +1061,18 @@ } } }, + "node_modules/@hono/node-server": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", + "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@inquirer/ansi": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", @@ -1197,6 +1210,46 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@mswjs/interceptors": { "version": "0.41.9", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", @@ -2165,6 +2218,39 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2563,6 +2649,37 @@ "node": ">=6.6.0" } }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -2890,6 +3007,27 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2943,6 +3081,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -2968,6 +3125,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", @@ -2985,6 +3148,22 @@ "fast-string-truncated-width": "^3.0.2" } }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-wrap-ansi": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", @@ -3265,6 +3444,15 @@ "set-cookie-parser": "^3.0.1" } }, + "node_modules/hono": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", + "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -3358,6 +3546,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3421,6 +3618,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3482,6 +3694,18 @@ "node": ">=6" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3746,6 +3970,15 @@ "node": ">=18" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -3892,6 +4125,15 @@ "node": ">= 0.8" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -3929,6 +4171,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/playwright": { "version": "1.57.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", @@ -4214,7 +4465,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4436,6 +4686,27 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -5192,6 +5463,21 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "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", @@ -5391,6 +5677,15 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index fc01ef4..996875e 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,9 @@ "dashboard:typecheck": "tsc -p apps/dashboard/tsconfig.json --noEmit", "start": "tsx src/index.ts", "dev": "tsx watch src/index.ts", + "mcp": "tsx src/mcp/server.ts", + "docs:api": "tsx scripts/generate-api-docs.ts", + "docs:api:check": "tsx scripts/generate-api-docs.ts --check", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage" @@ -24,6 +27,7 @@ "author": "", "license": "ISC", "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", "@tanstack/react-query": "^5.90.20", "axios": "^1.13.2", "better-sqlite3": "^13.0.3", diff --git a/packages/api-contracts/src/chats.ts b/packages/api-contracts/src/chats.ts index ca217f3..e545ede 100644 --- a/packages/api-contracts/src/chats.ts +++ b/packages/api-contracts/src/chats.ts @@ -75,7 +75,11 @@ export const ConversationDetailResponseSchema = ActiveChatSchema.extend({ staleMessages: z.boolean(), }).nullable(), }); -export const SuggestedReplyResponseSchema = z.strictObject({ suggestedReply: z.string() }); +export const SuggestedReplyResponseSchema = z.strictObject({ + suggestedReply: z.string(), + intent: z.unknown().optional(), + conversationState: z.unknown().optional(), +}); export const SendReplyRequestSchema = z.strictObject({ message: z.string().min(1) }); export const StartConversationRequestSchema = z.strictObject({ accountId: z.string().min(1), diff --git a/packages/api-contracts/src/messages.ts b/packages/api-contracts/src/messages.ts index 15b9f96..dfd2498 100644 --- a/packages/api-contracts/src/messages.ts +++ b/packages/api-contracts/src/messages.ts @@ -13,6 +13,11 @@ export const SentMessageSchema = z.strictObject({ responseCheckedAt: z.number().nullable(), strategyId: z.string().nullable(), chatRoomId: z.string().nullable(), + experimentId: z.string().nullable().optional(), + promptVersion: z.string().nullable().optional(), + modelId: z.string().nullable().optional(), + selectionReason: z.enum(['explore', 'exploit']).nullable().optional(), + sourceType: z.enum(['image', 'chat']).nullable().optional(), subreddit: z.string().optional(), postTitle: z.string().optional(), postUrl: z.string().optional(), diff --git a/packages/api-contracts/src/stats.ts b/packages/api-contracts/src/stats.ts index d545dfe..e2ccfe6 100644 --- a/packages/api-contracts/src/stats.ts +++ b/packages/api-contracts/src/stats.ts @@ -7,6 +7,9 @@ const PerformanceFields = { responseRate: z.number(), totalImages: z.number(), imageRate: z.number(), + pendingSent: z.number().optional(), + responseRateLow: z.number().optional(), + responseRateHigh: z.number().optional(), }; export const StatsResponseSchema = z.strictObject({ @@ -57,5 +60,19 @@ export const DailyTrendSchema = z.strictObject({ }); export const DailyTrendResponseSchema = z.array(DailyTrendSchema); +export const FunnelResponseSchema = z.strictObject({ + responseWindowHours: z.number(), + processed: z.number(), + sent: z.number(), + pending: z.number(), + mature: z.number(), + responded: z.number(), + engaged: z.number(), + declined: z.number(), + boundary: z.number(), + upsell: z.number(), + images: z.number(), +}); + export type StatsResponse = z.infer; export type TimersResponse = z.infer; diff --git a/packages/api-contracts/tests/contracts.test.ts b/packages/api-contracts/tests/contracts.test.ts index 683415d..30c28d8 100644 --- a/packages/api-contracts/tests/contracts.test.ts +++ b/packages/api-contracts/tests/contracts.test.ts @@ -21,6 +21,7 @@ import { StrategyPerformanceResponseSchema, SubredditPerformanceResponseSchema, DailyTrendResponseSchema, + FunnelResponseSchema, SuccessfulExamplesResponseSchema, TimersResponseSchema, } from '../src/index.js'; @@ -90,6 +91,11 @@ describe('dashboard endpoint contracts', () => { responseCheckedAt: null, strategyId: null, chatRoomId: null, + experimentId: 'message:variant-a:abc123', + promptVersion: 'abc123', + modelId: 'openai/gpt-5.2', + selectionReason: 'exploit', + sourceType: 'image', }]).success).toBe(true); expect(SuccessfulExamplesResponseSchema.safeParse([{ id: 1, @@ -124,6 +130,10 @@ describe('dashboard endpoint contracts', () => { expect(DailyTrendResponseSchema.safeParse([{ date: '2026-08-08', totalSent: 10, totalResponses: 4, responseRate: 40, totalImages: 2, imageRate: 20, }]).success).toBe(true); + expect(FunnelResponseSchema.safeParse({ + responseWindowHours: 48, processed: 20, sent: 10, pending: 2, mature: 8, + responded: 4, engaged: 3, declined: 1, boundary: 0, upsell: 0, images: 2, + }).success).toBe(true); expect(DailyTrendResponseSchema.safeParse([{ date: '2026-08-08', totalSent: '10' }]).success).toBe(false); }); diff --git a/scripts/generate-api-docs.ts b/scripts/generate-api-docs.ts new file mode 100644 index 0000000..60c9a84 --- /dev/null +++ b/scripts/generate-api-docs.ts @@ -0,0 +1,76 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { API_OPERATIONS } from '../src/web/api-catalog.js'; + +const rows = API_OPERATIONS.map((operation) => + `| \`${operation.method}\` | \`${operation.path}\` | ${operation.summary} | ${operation.access} | ${operation.mutates ? 'yes' : 'no'} |`, +).join('\n'); + +const document = `# RedditBot API + +This file is generated from \`src/web/api-catalog.ts\`. Run \`npm run docs:api\` +after changing the HTTP surface. The live OpenAPI 3.1 document is available at +\`GET /api/openapi.json\`, and compact agent discovery is available at +\`GET /api/v1/capabilities\`. + +## Authentication + +The dashboard token is an administrator credential. Browser users can exchange +it for an HttpOnly, SameSite=Strict session; API clients can send it as a bearer +token. + +Optional least-privilege bearer credentials are configured with: + +- \`REDDIT_BOT_AGENT_READ_TOKEN\`: GET, HEAD, SSE, and private-media reads except debug routes. +- \`REDDIT_BOT_AGENT_CONTROL_TOKEN\`: read access plus mutations under \`/api/v1\`. + +Control credentials cannot send messages, import sessions, activate accounts, +or update legacy configuration endpoints. Those operations require the +administrator dashboard token. + +## Agent entry points + +- \`GET /healthz\`: unauthenticated process liveness with no private state. +- \`GET /api/v1/readyz\`: component readiness and authentication degradation. +- \`GET /api/v1/overview\`: bot, account, sync, rate-limit, and recent-error snapshot. +- \`GET /api/v1/control\`: current running state. +- \`POST /api/v1/control/start\`, \`/stop\`, or \`POST /api/v1/control\`: explicit bot control. +- \`POST /api/v1/jobs\`: tracked chat/media sync; poll \`GET /api/v1/jobs/:id\`. +- \`GET /api/events/stream\`: replayable typed SSE events. + +## MCP server + +Run the stdio MCP adapter with \`npm run mcp\`. It connects to +\`REDDIT_BOT_API_URL\` (default \`http://127.0.0.1:3000\`) and reads its bearer +credential from \`REDDIT_BOT_AGENT_TOKEN\`, then the scoped agent variables, and +finally \`WEB_DASHBOARD_TOKEN\`. + +Read tools cover overview, readiness, accounts, activity, conversations, +errors, sync state, and performance. Control tools include \`start_bot\`, +\`stop_bot\`, \`set_bot_running\`, \`trigger_sync\`, and \`get_job\`. External +message tools are separately annotated as destructive/non-idempotent and need +an administrator credential. + +## Operations + +| Method | Path | Summary | Access | Mutates | +|---|---|---|---|---| +${rows} + +## Error model + +Errors use JSON with an \`error\` string and optional \`detail\`. Authentication +failures are \`401\`; recognized but insufficient agent credentials receive +\`403\`; invalid inputs receive \`400\`; unavailable services receive \`503\`. +Long-running agent jobs expose failure text in the job resource. +`; + +const target = path.resolve(process.cwd(), 'docs/api.md'); +if (process.argv.includes('--check')) { + if (!fs.existsSync(target) || fs.readFileSync(target, 'utf8') !== document) { + process.stderr.write('docs/api.md is stale; run npm run docs:api\n'); + process.exitCode = 1; + } +} else { + fs.writeFileSync(target, document); +} diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 8d63023..9de0e05 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -16,6 +16,7 @@ import { } from './database/repositories.js'; import { BrowserClient } from './browser/client.js'; import { RedditApiClient } from './reddit/api.js'; +import { RedditRequestCoordinator } from './reddit/request-coordinator.js'; import { OpenRouterClient } from './ai/openrouter.js'; import { MonitorService } from './services/monitor.js'; import { ProcessorService } from './services/processor.js'; @@ -158,7 +159,7 @@ export async function bootstrap( } // Reddit API client (shares browser context for auth) - const redditApi = new RedditApiClient(config, logger); + const redditApi = new RedditApiClient(config, logger, new RedditRequestCoordinator()); redditApi.setBrowserContext(browserClient.getContext()!); // Dedicated page for the response checker (background scraping that @@ -273,6 +274,7 @@ export async function bootstrap( messagesRepo, redditUsersRepo, accountAuth, + conversationOutcomesRepo, ); const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs, accountAuth); diff --git a/src/config/index.ts b/src/config/index.ts index bfdd1fe..4452f8d 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -108,7 +108,7 @@ export const ConfigSchema = z.object({ imageAnalysisPrompt: z.string(), messageGenerationPrompt: z.string(), chatOpenerPrompt: z.string().default( - 'You are writing a cold opener DM to a Reddit user who just posted in a chat/sexting subreddit looking for someone to chat with.\n\nPost title: {title}\nPost body: {selftext}\nAuthor: u/{author}\nDetected gender of poster: {gender}\n\nYOUR PERSONA:\n- You are male, based in the UK, pick a random age between 30 and 35 for this message\n- Roughly half the time, mention (or imply) you\'re married/have a wife and are looking for something on the side; the other half, don\'t mention relationship status\n- Don\'t force the married angle if their post wouldn\'t vibe with it\n- Use British English spelling and phrasing where it feels natural\n\nWrite a casual Reddit DM (1-2 sentences) opening a chat.\n\nMUST follow:\n- Lead with your age/gender/location tag (e.g. "32M UK", "34M UK married") when it fits naturally\n- Reference something SPECIFIC from their title/body (their mood, what they\'re into, vibe)\n- Be confident and direct, not formal\n- Use proper capitalisation, no AI/clinical language\n- End with a hook that invites a reply (ask them something, or offer something playful)\n\nDO NOT:\n- Use words like "captivating", "alluring", "intriguing"\n- Open with "Hey there" / "How\'s your day"\n- Write more than 2 sentences\n- Claim to be a different age/gender than the persona above\n- Use em dashes (—) or en dashes (–). Use commas, full stops, or separate sentences instead.\n\nJust write the message, nothing else.', + 'Write a truthful, respectful opener to an adult Reddit user who explicitly asked to chat.\n\nPost title: {title}\nPost body: {selftext}\nAuthor: u/{author}\nSelf-declared gender: {gender}\n\nUse British English where natural. Reference something specific from the post, keep it to 1-2 sentences, and end with an easy invitation to reply. Do not invent age, relationship status, location, identity, shared interests, or personal history. Avoid pressure, sexual escalation, clinical language, and generic compliments. Just write the message.', ), }), rateLimit: z.object({ @@ -129,27 +129,33 @@ export const ConfigSchema = z.object({ }).default({ syncIntervalMs: 1800000, syncEnabled: true }), strategies: z.object({ explorationRate: z.number().min(0).max(1).default(0.2), - minSamples: z.number().default(5), + minSamples: z.number().int().positive().transform((value) => Math.max(30, value)).default(30), + responseWindowHours: z.number().positive().default(48), variants: z.array(z.object({ id: z.string(), weight: z.number().default(1), promptOverride: z.string(), })).default([]), - }).default({ explorationRate: 0.2, minSamples: 5, variants: [] }), + }).default({ explorationRate: 0.2, minSamples: 30, responseWindowHours: 48, variants: [] }), chatStrategies: z.object({ explorationRate: z.number().min(0).max(1).default(0.2), - minSamples: z.number().default(5), + minSamples: z.number().int().positive().transform((value) => Math.max(30, value)).default(30), + responseWindowHours: z.number().positive().default(48), variants: z.array(z.object({ id: z.string(), weight: z.number().default(1), promptOverride: z.string(), })).default([]), - }).default({ explorationRate: 0.2, minSamples: 5, variants: [] }), + }).default({ explorationRate: 0.2, minSamples: 30, responseWindowHours: 48, variants: [] }), web: z.object({ enabled: z.boolean().default(true), port: z.number().default(3000), /** Prefer WEB_DASHBOARD_TOKEN so secrets do not live in config.yaml. */ authToken: z.string().min(8).optional(), + /** Optional least-privilege bearer token for GET/SSE agent access. */ + agentReadToken: z.string().min(8).optional(), + /** Optional bearer token for /api/v1 controls and jobs as well as reads. */ + agentControlToken: z.string().min(8).optional(), /** Dangerous diagnostic endpoints are disabled unless explicitly enabled. */ debugEnabled: z.boolean().default(false), }).default({ enabled: true, port: 3000, debugEnabled: false }), @@ -195,6 +201,14 @@ export function loadConfig(store?: ConfigPersistence): Config { if (!rawConfig.web) rawConfig.web = {}; rawConfig.web.authToken = process.env.WEB_DASHBOARD_TOKEN; } + if (process.env.REDDIT_BOT_AGENT_READ_TOKEN?.trim()) { + if (!rawConfig.web) rawConfig.web = {}; + rawConfig.web.agentReadToken = process.env.REDDIT_BOT_AGENT_READ_TOKEN; + } + if (process.env.REDDIT_BOT_AGENT_CONTROL_TOKEN?.trim()) { + if (!rawConfig.web) rawConfig.web = {}; + rawConfig.web.agentControlToken = process.env.REDDIT_BOT_AGENT_CONTROL_TOKEN; + } if (!rawConfig.openrouter.apiKey) { throw new Error('OPENROUTER_API_KEY environment variable is required'); diff --git a/src/config/persistence.ts b/src/config/persistence.ts index 20e58e3..9e1dd0c 100644 --- a/src/config/persistence.ts +++ b/src/config/persistence.ts @@ -3,7 +3,7 @@ import { ConfigSchema, type Config } from './index.js'; type StoredConfig = Omit & { openrouter: Omit; - web: Omit; + web: Omit; }; /** @@ -45,7 +45,12 @@ export class ConfigStore { function toStoredConfig(config: Config): StoredConfig { const { database: _database, ...applicationConfig } = config; const { apiKey: _apiKey, ...openrouter } = applicationConfig.openrouter; - const { authToken: _authToken, ...web } = applicationConfig.web; + const { + authToken: _authToken, + agentReadToken: _agentReadToken, + agentControlToken: _agentControlToken, + ...web + } = applicationConfig.web; return { ...applicationConfig, openrouter, web }; } @@ -58,7 +63,12 @@ function withBootstrapSettings(stored: unknown, environment: NodeJS.ProcessEnv): ...record, database: { path: environment.DATABASE_PATH ?? './data/reddit-bot.db' }, openrouter: { ...openrouter, apiKey: environment.OPENROUTER_API_KEY }, - web: { ...web, authToken: environment.WEB_DASHBOARD_TOKEN }, + web: { + ...web, + authToken: environment.WEB_DASHBOARD_TOKEN, + agentReadToken: optionalSecret(environment.REDDIT_BOT_AGENT_READ_TOKEN), + agentControlToken: optionalSecret(environment.REDDIT_BOT_AGENT_CONTROL_TOKEN), + }, }; } @@ -68,6 +78,10 @@ function objectValue(value: unknown): Record { : {}; } +function optionalSecret(value: string | undefined): string | undefined { + return value?.trim() ? value : undefined; +} + function validateConfig(raw: unknown): Config { const result = ConfigSchema.safeParse(raw); if (!result.success) { diff --git a/src/database/client.ts b/src/database/client.ts index 2c2e32d..9bb8c9f 100644 --- a/src/database/client.ts +++ b/src/database/client.ts @@ -201,6 +201,22 @@ export function runMigrations(db: Database.Database): void { `); }, () => migrateAccountOwnership(db), + () => { + addColumnIfNotExists(db, 'sent_messages', 'experiment_id', 'TEXT'); + addColumnIfNotExists(db, 'sent_messages', 'prompt_version', 'TEXT'); + addColumnIfNotExists(db, 'sent_messages', 'model_id', 'TEXT'); + addColumnIfNotExists(db, 'sent_messages', 'selection_reason', 'TEXT'); + addColumnIfNotExists(db, 'sent_messages', 'source_type', 'TEXT'); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_sent_messages_experiment + ON sent_messages(account_id, experiment_id, strategy_id, sent_at); + CREATE TABLE IF NOT EXISTS recipient_suppressions ( + recipient TEXT PRIMARY KEY COLLATE NOCASE, + reason TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + `); + }, ]; let currentVersion = db.pragma('user_version', { simple: true }) as number; diff --git a/src/database/repositories.ts b/src/database/repositories.ts index 6f9706e..c626cb2 100644 --- a/src/database/repositories.ts +++ b/src/database/repositories.ts @@ -33,6 +33,11 @@ export interface SentMessageRecord { responseCheckedAt: number | null; strategyId: string | null; chatRoomId: string | null; + experimentId?: string | null; + promptVersion?: string | null; + modelId?: string | null; + selectionReason?: 'explore' | 'exploit' | null; + sourceType?: 'image' | 'chat' | null; subreddit?: string; postTitle?: string; postUrl?: string; @@ -59,6 +64,9 @@ export interface StrategyStats { responseRate: number; totalImages: number; imageRate: number; + pendingSent?: number; + responseRateLow?: number; + responseRateHigh?: number; } export interface SubredditStats { @@ -68,6 +76,9 @@ export interface SubredditStats { responseRate: number; totalImages: number; imageRate: number; + pendingSent?: number; + responseRateLow?: number; + responseRateHigh?: number; } export interface DailyTrend { @@ -115,15 +126,17 @@ export class RecipientHistoryRepository { constructor(db: Database.Database) { this.hasSentToStmt = db.prepare(` - SELECT 1 - FROM sent_messages - WHERE recipient = ? COLLATE NOCASE + SELECT 1 FROM ( + SELECT recipient FROM sent_messages WHERE recipient = ? COLLATE NOCASE + UNION ALL + SELECT recipient FROM recipient_suppressions WHERE recipient = ? COLLATE NOCASE + ) LIMIT 1 `); } hasSentTo(recipient: string): boolean { - return this.hasSentToStmt.get(recipient) !== undefined; + return this.hasSentToStmt.get(recipient, recipient) !== undefined; } } @@ -385,6 +398,14 @@ export class PostsRepository { return this.existsStmt.get(this.accountId, postId) !== undefined; } + /** + * Discovery is shared by all accounts, so a post handled or rejected by one + * account must not be rediscovered by another account on a later poll. + */ + isProcessedAnywhere(postId: string): boolean { + return this.db.prepare('SELECT 1 FROM processed_posts WHERE id = ? LIMIT 1').get(postId) !== undefined; + } + insert(post: { id: string; subreddit: string; @@ -473,8 +494,10 @@ export class MessagesRepository { private readonly accountId: string = LEGACY_UNASSIGNED_ACCOUNT_ID, ) { this.insertStmt = db.prepare(` - INSERT INTO sent_messages (account_id, post_id, recipient, subject, message, sent_at, strategy_id, chat_room_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO sent_messages ( + account_id, post_id, recipient, subject, message, sent_at, strategy_id, chat_room_id, + experiment_id, prompt_version, model_id, selection_reason, source_type + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); this.insertChatStmt = db.prepare(` @@ -530,7 +553,7 @@ export class MessagesRepository { this.markImageStmt = db.prepare(` UPDATE sent_messages SET got_image = 1, got_response = 1, response_checked_at = ? - WHERE id = ? + WHERE account_id = ? AND id = ? `); this.markCheckedStmt = db.prepare(` @@ -550,6 +573,11 @@ export class MessagesRepository { sentAt: number; strategyId?: string | null; chatRoomId?: string | null; + experimentId?: string | null; + promptVersion?: string | null; + modelId?: string | null; + selectionReason?: 'explore' | 'exploit' | null; + sourceType?: 'image' | 'chat' | null; }): number { const result = this.insertStmt.run( this.accountId, @@ -560,6 +588,11 @@ export class MessagesRepository { message.sentAt, message.strategyId || null, message.chatRoomId ?? null, + message.experimentId ?? null, + message.promptVersion ?? null, + message.modelId ?? null, + message.selectionReason ?? null, + message.sourceType ?? null, ); return result.lastInsertRowid as number; } @@ -627,7 +660,7 @@ export class MessagesRepository { } markGotImage(messageId: number): void { - this.markImageStmt.run(Date.now(), messageId); + this.markImageStmt.run(Date.now(), this.accountId, messageId); } markChecked(messageId: number): void { @@ -638,7 +671,8 @@ export class MessagesRepository { return this.hasSentToStmt.get(this.accountId, recipient) !== undefined; } - getStrategyStats(): StrategyStats[] { + getStrategyStats(matureBefore?: number): StrategyStats[] { + const maturityClause = matureBefore === undefined ? '' : 'AND sent_at <= ?'; const rows = this.db.prepare(` SELECT strategy_id, @@ -646,25 +680,33 @@ export class MessagesRepository { SUM(CASE WHEN got_response = 1 THEN 1 ELSE 0 END) as total_responses, SUM(CASE WHEN got_image = 1 THEN 1 ELSE 0 END) as total_images FROM sent_messages - WHERE account_id = ? AND strategy_id IS NOT NULL + WHERE account_id = ? AND strategy_id IS NOT NULL ${maturityClause} GROUP BY strategy_id - `).all(this.accountId) as Record[]; + `).all(...(matureBefore === undefined ? [this.accountId] : [this.accountId, matureBefore])) as Record[]; - return rows.map(row => ({ - strategyId: row.strategy_id as string, - totalSent: row.total_sent as number, - totalResponses: row.total_responses as number, - responseRate: (row.total_sent as number) > 0 - ? Math.round(((row.total_responses as number) / (row.total_sent as number)) * 100) - : 0, - totalImages: row.total_images as number, - imageRate: (row.total_sent as number) > 0 - ? Math.round(((row.total_images as number) / (row.total_sent as number)) * 100) - : 0, - })); + const pending = matureBefore === undefined ? new Map() : new Map( + (this.db.prepare(` + SELECT strategy_id, COUNT(*) AS total + FROM sent_messages + WHERE account_id = ? AND strategy_id IS NOT NULL AND sent_at > ? + GROUP BY strategy_id + `).all(this.accountId, matureBefore) as Array<{ strategy_id: string; total: number }>) + .map((row) => [row.strategy_id, row.total]), + ); + + return rows.map(row => performanceStats( + { + strategyId: row.strategy_id as string, + totalSent: row.total_sent as number, + totalResponses: row.total_responses as number, + totalImages: row.total_images as number, + }, + matureBefore === undefined ? undefined : pending.get(row.strategy_id as string) ?? 0, + )); } - getSubredditStats(): SubredditStats[] { + getSubredditStats(matureBefore?: number): SubredditStats[] { + const maturityClause = matureBefore === undefined ? '' : 'AND sm.sent_at <= ?'; const rows = this.db.prepare(` SELECT pp.subreddit, @@ -673,23 +715,31 @@ export class MessagesRepository { SUM(CASE WHEN sm.got_image = 1 THEN 1 ELSE 0 END) as total_images FROM sent_messages sm JOIN processed_posts pp ON sm.account_id = pp.account_id AND sm.post_id = pp.id - WHERE sm.account_id = ? + WHERE sm.account_id = ? ${maturityClause} GROUP BY pp.subreddit ORDER BY total_sent DESC - `).all(this.accountId) as Record[]; + `).all(...(matureBefore === undefined ? [this.accountId] : [this.accountId, matureBefore])) as Record[]; - return rows.map(row => ({ - subreddit: row.subreddit as string, - totalSent: row.total_sent as number, - totalResponses: row.total_responses as number, - responseRate: (row.total_sent as number) > 0 - ? Math.round(((row.total_responses as number) / (row.total_sent as number)) * 100) - : 0, - totalImages: row.total_images as number, - imageRate: (row.total_sent as number) > 0 - ? Math.round(((row.total_images as number) / (row.total_sent as number)) * 100) - : 0, - })); + const pending = matureBefore === undefined ? new Map() : new Map( + (this.db.prepare(` + SELECT pp.subreddit, COUNT(*) AS total + FROM sent_messages sm + JOIN processed_posts pp ON sm.account_id = pp.account_id AND sm.post_id = pp.id + WHERE sm.account_id = ? AND sm.sent_at > ? + GROUP BY pp.subreddit + `).all(this.accountId, matureBefore) as Array<{ subreddit: string; total: number }>) + .map((row) => [row.subreddit, row.total]), + ); + + return rows.map(row => performanceStats( + { + subreddit: row.subreddit as string, + totalSent: row.total_sent as number, + totalResponses: row.total_responses as number, + totalImages: row.total_images as number, + }, + matureBefore === undefined ? undefined : pending.get(row.subreddit as string) ?? 0, + )); } getResponseRateTrend(days: number = 30): DailyTrend[] { @@ -721,6 +771,50 @@ export class MessagesRepository { })); } + getFunnelStats(matureBefore: number): { + processed: number; + sent: number; + pending: number; + mature: number; + responded: number; + engaged: number; + declined: number; + boundary: number; + upsell: number; + images: number; + } { + const row = this.db.prepare(` + SELECT + (SELECT COUNT(*) FROM processed_posts WHERE account_id = @accountId) AS processed, + COUNT(*) AS sent, + SUM(CASE WHEN sent_at > @matureBefore THEN 1 ELSE 0 END) AS pending, + SUM(CASE WHEN sent_at <= @matureBefore THEN 1 ELSE 0 END) AS mature, + SUM(CASE WHEN got_response = 1 THEN 1 ELSE 0 END) AS responded, + SUM(CASE WHEN got_image = 1 THEN 1 ELSE 0 END) AS images, + SUM(CASE WHEN co.outcome = 'engaged' THEN 1 ELSE 0 END) AS engaged, + SUM(CASE WHEN co.outcome = 'declined' THEN 1 ELSE 0 END) AS declined, + SUM(CASE WHEN co.outcome = 'boundary' THEN 1 ELSE 0 END) AS boundary, + SUM(CASE WHEN co.outcome = 'upsell' THEN 1 ELSE 0 END) AS upsell + FROM sent_messages sm + LEFT JOIN conversation_outcomes co + ON co.account_id = sm.account_id AND co.sent_message_id = sm.id + WHERE sm.account_id = @accountId + AND (sm.post_id IS NOT NULL OR sm.strategy_id IS NOT NULL) + `).get({ accountId: this.accountId, matureBefore }) as Record; + return { + processed: row.processed ?? 0, + sent: row.sent ?? 0, + pending: row.pending ?? 0, + mature: row.mature ?? 0, + responded: row.responded ?? 0, + engaged: row.engaged ?? 0, + declined: row.declined ?? 0, + boundary: row.boundary ?? 0, + upsell: row.upsell ?? 0, + images: row.images ?? 0, + }; + } + private mapRow(row: Record): SentMessageRecord { return { id: row.id as number, @@ -734,6 +828,11 @@ export class MessagesRepository { responseCheckedAt: row.response_checked_at as number | null, strategyId: (row.strategy_id as string) || null, chatRoomId: (row.chat_room_id as string) ?? null, + experimentId: (row.experiment_id as string | null) ?? null, + promptVersion: (row.prompt_version as string | null) ?? null, + modelId: (row.model_id as string | null) ?? null, + selectionReason: (row.selection_reason as 'explore' | 'exploit' | null) ?? null, + sourceType: (row.source_type as 'image' | 'chat' | null) ?? null, subreddit: (row.subreddit as string) || undefined, postTitle: (row.post_title as string) || undefined, postUrl: (row.post_url as string) || undefined, @@ -743,6 +842,43 @@ export class MessagesRepository { } } +function performanceStats(base: T, pendingSent?: number): T & { + responseRate: number; + imageRate: number; + pendingSent?: number; + responseRateLow?: number; + responseRateHigh?: number; +} { + const responseRate = base.totalSent > 0 + ? Math.round((base.totalResponses / base.totalSent) * 100) + : 0; + const imageRate = base.totalSent > 0 + ? Math.round((base.totalImages / base.totalSent) * 100) + : 0; + if (pendingSent === undefined) return { ...base, responseRate, imageRate }; + const [responseRateLow, responseRateHigh] = wilsonInterval(base.totalResponses, base.totalSent); + return { ...base, responseRate, imageRate, pendingSent, responseRateLow, responseRateHigh }; +} + +function wilsonInterval(successes: number, total: number): [number, number] { + if (total <= 0) return [0, 100]; + const z = 1.96; + const proportion = successes / total; + const denominator = 1 + (z * z) / total; + const centre = (proportion + (z * z) / (2 * total)) / denominator; + const margin = z * Math.sqrt( + (proportion * (1 - proportion) + (z * z) / (4 * total)) / total, + ) / denominator; + return [ + Math.round(Math.max(0, centre - margin) * 100), + Math.round(Math.min(1, centre + margin) * 100), + ]; +} + export class SuccessfulMessagesRepository { private insertStmt: Database.Statement; private getRecentStmt: Database.Statement; @@ -911,6 +1047,14 @@ export class ConversationOutcomesRepository { `).get(this.accountId, sentMessageId) as { outcome: ConversationOutcome; response_snippet: string | null } | undefined; return row ? { outcome: row.outcome, responseSnippet: row.response_snippet } : undefined; } + + suppressRecipient(recipient: string, reason: string): void { + this.db.prepare(` + INSERT INTO recipient_suppressions (recipient, reason, created_at) + VALUES (?, ?, ?) + ON CONFLICT(recipient) DO UPDATE SET reason = excluded.reason + `).run(recipient, reason, Date.now()); + } } export interface RedditUserRecord { diff --git a/src/main-loop.ts b/src/main-loop.ts index 73d9514..a722be9 100644 --- a/src/main-loop.ts +++ b/src/main-loop.ts @@ -291,6 +291,14 @@ export async function runMainLoop(app: AppContainer): Promise { await rateLimiter.throttle( async () => { + if (config.browser.dryRun) { + const reason = 'Dry run: message prepared but not sent'; + logger.info(`${reason} for u/${post.author}`); + postsRepo.setError(post.id, reason); + webServer?.logActivity('skipped', `${reason} to u/${post.author}`, post.author); + webServer?.pushLog('info', `🧪 ${reason} to u/${post.author}`); + return false; + } if (!recipientDeduplicator.tryReserve(post.author)) { const reason = 'User already contacted across accounts'; logger.info(`Skipping u/${post.author} - ${reason.toLowerCase()}`); @@ -335,6 +343,11 @@ export async function runMainLoop(app: AppContainer): Promise { sentAt: Date.now(), strategyId: processed.strategyId, chatRoomId: chat.roomId, + experimentId: processed.experimentId, + promptVersion: processed.promptVersion, + modelId: processed.modelId, + selectionReason: processed.selectionReason, + sourceType: processed.sourceType, }); const currentCount = currentHourlySendCount(rateLimiter); @@ -410,14 +423,72 @@ export async function runMainLoop(app: AppContainer): Promise { } async function runConcurrentAccountRuntimes(app: AppContainer): Promise { - const { logger, accountRuntimeManager, browserClient, db, webServer } = app; - logger.info(`Running ${accountRuntimeManager.list().length} independent Reddit account runtime(s)`); + const { config, logger, accountRuntimeManager, browserClient, db, webServer } = app; + logger.info(`Running ${accountRuntimeManager.list().length} account runtime(s) with shared post discovery`); await new Promise((resolve) => { let shuttingDown = false; + const controller = new AbortController(); + let nextScannerIndex = 0; + + const discoverAndDispatch = async (): Promise => { + const runtimes = accountRuntimeManager.list() + .filter((snapshot) => snapshot.state === 'running') + .map((snapshot) => accountRuntimeManager.get(snapshot.accountId)) + .filter((runtime): runtime is NonNullable => runtime !== null); + if (runtimes.length === 0) return; + + // Rotate the authenticated browser used to read Reddit, while keeping + // selection and delivery centralised. A subreddit is therefore fetched + // once per cycle, never once per account. + const scanner = runtimes[nextScannerIndex % runtimes.length]; + nextScannerIndex += 1; + if (!(await scanner.canAcceptPost())) return; + + const posts = await scanner.resources.monitorService.getUnprocessedPosts(); + const post = posts[0]; + if (!post) return; + + const recipients = (await Promise.all(runtimes.map(async (runtime) => ({ + runtime, + available: await runtime.canAcceptPost(), + })))).filter((candidate) => candidate.available) + .map((candidate) => candidate.runtime) + .sort((left, right) => ( + left.resources.rateLimiter.getStats().messagesThisHour + - right.resources.rateLimiter.getStats().messagesThisHour + )); + const recipient = recipients[0]; + if (!recipient) { + logger.debug(`Eligible post ${post.id} found, but no account is currently available`); + return; + } + + logger.info(`Assigning eligible post ${post.id} (r/${post.subreddit}) to u/${recipient.session.redditUsername}`); + await recipient.dispatchPost(post, controller.signal); + }; + + const discoveryLoop = (async (): Promise => { + while (!controller.signal.aborted) { + try { + if (webServer?.isRunning() ?? true) await discoverAndDispatch(); + } catch (error) { + if (controller.signal.aborted) return; + if (error instanceof RedditRateLimitError) { + await sleep(error.retryAfterMs + 1_000, controller.signal).catch(() => undefined); + continue; + } + logger.error('Shared post discovery failed:', error); + } + await sleep(config.reddit.pollIntervalMs, controller.signal).catch(() => undefined); + } + })(); + const shutdown = async (): Promise => { if (shuttingDown) return; shuttingDown = true; + controller.abort(); logger.info('Stopping all Reddit account runtimes...'); + await discoveryLoop; await accountRuntimeManager.stopAll(); webServer?.stop(); await browserClient.close(); diff --git a/src/mcp/api-client.ts b/src/mcp/api-client.ts new file mode 100644 index 0000000..f11293e --- /dev/null +++ b/src/mcp/api-client.ts @@ -0,0 +1,75 @@ +export interface RedditBotApiClientOptions { + baseUrl?: string; + token?: string; + fetchImplementation?: typeof fetch; +} + +export class RedditBotApiError extends Error { + constructor(message: string, readonly status: number) { + super(message); + this.name = 'RedditBotApiError'; + } +} + +export class RedditBotApiClient { + private readonly baseUrl: string; + private readonly token: string; + private readonly fetchImplementation: typeof fetch; + + constructor(options: RedditBotApiClientOptions = {}) { + this.baseUrl = (options.baseUrl ?? process.env.REDDIT_BOT_API_URL ?? 'http://127.0.0.1:3000').replace(/\/$/, ''); + this.token = options.token + ?? process.env.REDDIT_BOT_AGENT_TOKEN + ?? process.env.REDDIT_BOT_AGENT_CONTROL_TOKEN + ?? process.env.REDDIT_BOT_AGENT_READ_TOKEN + ?? process.env.WEB_DASHBOARD_TOKEN + ?? ''; + this.fetchImplementation = options.fetchImplementation ?? fetch; + if (!this.token) { + throw new Error('MCP server requires REDDIT_BOT_AGENT_TOKEN or WEB_DASHBOARD_TOKEN'); + } + new URL(this.baseUrl); + } + + get(path: string): Promise { + return this.request(path); + } + + post(path: string, body?: unknown): Promise { + return this.request(path, { + method: 'POST', + ...(body === undefined ? {} : { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + }); + } + + private async request(path: string, init: RequestInit = {}): Promise { + const response = await this.fetchImplementation(`${this.baseUrl}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${this.token}`, + Accept: 'application/json', + ...init.headers, + }, + }); + const text = await response.text(); + let payload: unknown = null; + if (text) { + try { + payload = JSON.parse(text) as unknown; + } catch { + payload = text; + } + } + if (!response.ok) { + const message = typeof payload === 'object' && payload !== null && 'error' in payload + && typeof payload.error === 'string' + ? payload.error + : `RedditBot API request failed (${response.status})`; + throw new RedditBotApiError(message, response.status); + } + return payload; + } +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..5d9dc36 --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,172 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; +import { RedditBotApiClient } from './api-client.js'; + +function result(value: unknown) { + const structuredContent = isRecord(value) ? value : { items: value }; + return { + content: [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }], + structuredContent, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function createRedditBotMcpServer(client = new RedditBotApiClient()): McpServer { + const server = new McpServer({ name: 'reddit-bot', version: '1.0.0' }); + const readAnnotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }; + const controlAnnotations = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }; + const sendAnnotations = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }; + + server.registerTool('get_overview', { + description: 'Get one consolidated snapshot of bot, account, authentication, sync, rate-limit, and recent error state.', + annotations: readAnnotations, + }, async () => result(await client.get('/api/v1/overview'))); + + server.registerTool('get_readiness', { + description: 'Check whether core application components are initialized and whether account authentication is degraded.', + annotations: readAnnotations, + }, async () => result(await client.get('/api/v1/readyz'))); + + server.registerTool('list_accounts', { + description: 'List configured Reddit accounts and their runtime state.', + annotations: readAnnotations, + }, async () => result(await client.get('/api/accounts'))); + + server.registerTool('list_recent_activity', { + description: 'List recent observable bot activity.', + inputSchema: { limit: z.number().int().min(1).max(200).default(50) }, + annotations: readAnnotations, + }, async ({ limit }) => { + const activity = await client.get('/api/activity'); + return result(Array.isArray(activity) ? activity.slice(0, limit) : activity); + }); + + server.registerTool('list_conversations', { + description: 'List cached Reddit conversations across running accounts.', + inputSchema: { includeImageMetadata: z.boolean().default(true) }, + annotations: readAnnotations, + }, async ({ includeImageMetadata }) => result(await client.get(`/api/conversations?includeImageMetadata=${includeImageMetadata}`))); + + server.registerTool('get_conversation', { + description: 'Get one conversation, its messages, owner, triggering post, and sync freshness state.', + inputSchema: { id: z.string().min(1).describe('Owned conversation ID returned by list_conversations') }, + annotations: readAnnotations, + }, async ({ id }) => result(await client.get(`/api/conversations/${encodeURIComponent(id)}`))); + + server.registerTool('list_recent_errors', { + description: 'List recent structured error logs without streaming the live event feed.', + inputSchema: { limit: z.number().int().min(1).max(100).default(20) }, + annotations: readAnnotations, + }, async ({ limit }) => { + const logs = await client.get('/api/logs?limit=500'); + const errors = Array.isArray(logs) + ? logs.filter((entry) => isRecord(entry) && entry.level === 'error').slice(-limit).reverse() + : logs; + return result(errors); + }); + + server.registerTool('get_sync_status', { + description: 'Get both chat and media synchronization state.', + annotations: readAnnotations, + }, async () => result({ + chat: await client.get('/api/chats/sync'), + media: await client.get('/api/media/sync'), + })); + + server.registerTool('get_performance', { + description: 'Get performance by subreddit and strategy plus the requested daily trend.', + inputSchema: { days: z.number().int().min(1).max(365).default(30) }, + annotations: readAnnotations, + }, async ({ days }) => result({ + bySubreddit: await client.get('/api/analytics/by-subreddit'), + byStrategy: await client.get('/api/analytics/by-strategy'), + funnel: await client.get('/api/analytics/funnel'), + trend: await client.get(`/api/analytics/trend?days=${days}`), + })); + + server.registerTool('get_control_status', { + description: 'Get bot start/stop state without changing it.', + annotations: readAnnotations, + }, async () => result(await client.get('/api/v1/control'))); + + server.registerTool('start_bot', { + description: 'Start bot processing. Fails safely if no Reddit account is fully authenticated.', + annotations: controlAnnotations, + }, async () => result(await client.post('/api/v1/control/start'))); + + server.registerTool('stop_bot', { + description: 'Stop bot processing gracefully. Calling this when already stopped is safe.', + annotations: controlAnnotations, + }, async () => result(await client.post('/api/v1/control/stop'))); + + server.registerTool('set_bot_running', { + description: 'Explicitly set the desired bot running state.', + inputSchema: { running: z.boolean() }, + annotations: controlAnnotations, + }, async ({ running }) => result(await client.post('/api/v1/control', { running }))); + + server.registerTool('trigger_sync', { + description: 'Queue a tracked chat or media sync job and return its job ID.', + inputSchema: { + type: z.enum(['chat-sync', 'media-sync']), + kind: z.enum(['delta', 'full']).default('delta'), + accountId: z.string().min(1).optional(), + }, + annotations: { ...controlAnnotations, idempotentHint: false }, + }, async (input) => result(await client.post('/api/v1/jobs', input))); + + server.registerTool('get_job', { + description: 'Get the current state and result of a tracked sync job.', + inputSchema: { id: z.string().uuid() }, + annotations: readAnnotations, + }, async ({ id }) => result(await client.get(`/api/v1/jobs/${encodeURIComponent(id)}`))); + + server.registerTool('send_reply', { + description: 'Send a message to an existing conversation. This has an external side effect and is not idempotent.', + inputSchema: { conversationId: z.string().min(1), message: z.string().min(1).max(10_000) }, + annotations: sendAnnotations, + }, async ({ conversationId, message }) => result(await client.post( + `/api/conversations/${encodeURIComponent(conversationId)}/send-reply`, + { message }, + ))); + + server.registerTool('start_conversation', { + description: 'Start a new Reddit conversation and send its first message. This has an external side effect and is not idempotent.', + inputSchema: { + accountId: z.string().min(1), + username: z.string().min(1), + message: z.string().min(1).max(10_000), + }, + annotations: sendAnnotations, + }, async (input) => result(await client.post('/api/conversations/start', input))); + + server.registerResource('openapi', 'redditbot://api/openapi', { + title: 'RedditBot OpenAPI', + description: 'Machine-readable HTTP API definition', + mimeType: 'application/json', + }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(await client.get('/api/openapi.json'), null, 2) }] })); + + server.registerResource('capabilities', 'redditbot://api/capabilities', { + title: 'RedditBot capabilities', + description: 'Operations with read/control/admin and mutation metadata', + mimeType: 'application/json', + }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(await client.get('/api/v1/capabilities'), null, 2) }] })); + + return server; +} + +async function main(): Promise { + const server = createRedditBotMcpServer(); + await server.connect(new StdioServerTransport()); +} + +if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { + main().catch((error: unknown) => { + process.stderr.write(`RedditBot MCP server failed: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/src/reddit/api.ts b/src/reddit/api.ts index 14bd8c0..0a9cedf 100644 --- a/src/reddit/api.ts +++ b/src/reddit/api.ts @@ -2,6 +2,12 @@ import axios, { AxiosInstance } from 'axios'; import type { BrowserContext } from 'playwright'; import type { Config } from '../config/index.js'; import type { Logger } from '../utils/logger.js'; +import type { RedditRequestScheduler } from './request-coordinator.js'; + +const uncoordinatedRequestScheduler: RedditRequestScheduler = { + schedule: async (request: () => Promise) => await request(), + extendCooldown: () => undefined, +}; export class RedditRateLimitError extends Error { constructor(public readonly retryAfterMs: number) { @@ -105,7 +111,10 @@ export class RedditApiClient { constructor( config: Config, - private logger: Logger + private logger: Logger, + // Production bootstraps supply one process-wide paced coordinator. Keep + // standalone clients independent for embedders and focused unit tests. + private readonly requestScheduler: RedditRequestScheduler = uncoordinatedRequestScheduler, ) { this.client = axios.create({ baseURL: 'https://www.reddit.com', @@ -143,15 +152,17 @@ export class RedditApiClient { if (cookieHeader) headers.Cookie = cookieHeader; try { - const response = await this.client.get( + const response = await this.scheduleRequest(() => this.client.get( `/user/${encodeURIComponent(username)}/about.json`, { params: { raw_json: 1 }, headers }, - ); + )); const id = response.data?.data?.id; if (typeof id !== 'string' || id.length === 0) return null; return id.startsWith('t2_') ? id : `t2_${id}`; } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 404) return null; + const rateLimitError = this.toRateLimitError(error); + if (rateLimitError) throw rateLimitError; throw error; } } @@ -164,10 +175,10 @@ export class RedditApiClient { headers['Cookie'] = cookieHeader; } - const response = await this.client.get(`/r/${subreddit}/new.json`, { + const response = await this.scheduleRequest(() => this.client.get(`/r/${subreddit}/new.json`, { params: { limit, raw_json: 1 }, headers, - }); + })); const posts: RedditPost[] = response.data.data.children .map((child: { data: RedditPost }) => child.data) @@ -185,12 +196,8 @@ export class RedditApiClient { this.logger.warn(`Subreddit r/${subreddit} is private or quarantined`); return []; } - if (error.response?.status === 429) { - const retryAfterMs = getRateLimitDelayMs(error.response.headers); - const rateLimitError = new RedditRateLimitError(retryAfterMs); - this.logger.warn(rateLimitError.message); - throw rateLimitError; - } + const rateLimitError = this.toRateLimitError(error); + if (rateLimitError) throw rateLimitError; } this.logger.error(`Error fetching posts from r/${subreddit}:`, error); throw error; @@ -310,10 +317,10 @@ export class RedditApiClient { headers['Cookie'] = cookieHeader; } - const response = await this.client.get(`/user/${encodeURIComponent(username)}/about.json`, { + const response = await this.scheduleRequest(() => this.client.get(`/user/${encodeURIComponent(username)}/about.json`, { params: { raw_json: 1 }, headers, - }); + })); const userData = response.data?.data; if (userData) { @@ -331,11 +338,8 @@ export class RedditApiClient { } } } catch (error) { - if (axios.isAxiosError(error) && error.response?.status === 429) { - const rateLimitError = new RedditRateLimitError(getRateLimitDelayMs(error.response.headers)); - this.logger.warn(rateLimitError.message); - throw rateLimitError; - } + const rateLimitError = this.toRateLimitError(error); + if (rateLimitError) throw rateLimitError; if (axios.isAxiosError(error) && error.response?.status === 404) { this.logger.warn(`User u/${username} profile not found`); return false; @@ -350,10 +354,10 @@ export class RedditApiClient { const cookieHeader = await this.getCookieHeader(); const headers: Record = {}; if (cookieHeader) headers.Cookie = cookieHeader; - const response = await this.client.get(`/user/${encodeURIComponent(username)}/overview.json`, { + const response = await this.scheduleRequest(() => this.client.get(`/user/${encodeURIComponent(username)}/overview.json`, { params: { limit: 25, raw_json: 1 }, headers, - }); + })); const children: RedditListingChild[] = Array.isArray(response.data?.data?.children) ? response.data.data.children : []; @@ -372,11 +376,8 @@ export class RedditApiClient { return true; } } catch (error) { - if (axios.isAxiosError(error) && error.response?.status === 429) { - const rateLimitError = new RedditRateLimitError(getRateLimitDelayMs(error.response.headers)); - this.logger.warn(rateLimitError.message); - throw rateLimitError; - } + const rateLimitError = this.toRateLimitError(error); + if (rateLimitError) throw rateLimitError; this.logger.warn(`Failed to check recent activity for u/${username}:`, error); } @@ -386,4 +387,26 @@ export class RedditApiClient { getPostUrl(post: RedditPost): string { return `https://www.reddit.com${post.permalink}`; } + + private toRateLimitError(error: unknown): RedditRateLimitError | null { + if (error instanceof RedditRateLimitError) return error; + if (!axios.isAxiosError(error) || error.response?.status !== 429) return null; + const rateLimitError = new RedditRateLimitError(getRateLimitDelayMs(error.response.headers)); + this.requestScheduler.extendCooldown(rateLimitError.retryAfterMs); + this.logger.warn(rateLimitError.message); + return rateLimitError; + } + + /** Record a 429 before releasing the shared request queue to its next waiter. */ + private async scheduleRequest(request: () => Promise): Promise { + return await this.requestScheduler.schedule(async () => { + try { + return await request(); + } catch (error) { + const rateLimitError = this.toRateLimitError(error); + if (rateLimitError) throw rateLimitError; + throw error; + } + }); + } } diff --git a/src/reddit/request-coordinator.ts b/src/reddit/request-coordinator.ts new file mode 100644 index 0000000..41c0f47 --- /dev/null +++ b/src/reddit/request-coordinator.ts @@ -0,0 +1,56 @@ +/** + * Coordinates every Reddit JSON request made by the process. Reddit applies + * limits to the shared origin/IP as well as to an individual signed-in user, + * so separate account runtimes must not independently retry or burst reads. + */ +export interface RedditRequestScheduler { + schedule(request: () => Promise): Promise; + extendCooldown(retryAfterMs: number): void; +} + +export interface RedditRequestCoordinatorDependencies { + now?: () => number; + sleep?: (ms: number) => Promise; +} + +const DEFAULT_MIN_REQUEST_INTERVAL_MS = 6_500; +const COOLDOWN_BUFFER_MS = 1_000; + +export class RedditRequestCoordinator implements RedditRequestScheduler { + private tail: Promise = Promise.resolve(); + private nextRequestAt = 0; + private readonly now: () => number; + private readonly sleep: (ms: number) => Promise; + + constructor( + private readonly minRequestIntervalMs = DEFAULT_MIN_REQUEST_INTERVAL_MS, + dependencies: RedditRequestCoordinatorDependencies = {}, + ) { + this.now = dependencies.now ?? Date.now; + this.sleep = dependencies.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + } + + async schedule(request: () => Promise): Promise { + let release: () => void = () => undefined; + const previous = this.tail; + this.tail = new Promise((resolve) => { release = resolve; }); + + await previous; + try { + const waitMs = this.nextRequestAt - this.now(); + if (waitMs > 0) await this.sleep(waitMs); + const result = await request(); + this.nextRequestAt = Math.max(this.nextRequestAt, this.now() + this.minRequestIntervalMs); + return result; + } finally { + release(); + } + } + + extendCooldown(retryAfterMs: number): void { + this.nextRequestAt = Math.max( + this.nextRequestAt, + this.now() + Math.max(0, retryAfterMs) + COOLDOWN_BUFFER_MS, + ); + } +} diff --git a/src/services/chat/service.ts b/src/services/chat/service.ts index ea52e27..65a568a 100644 --- a/src/services/chat/service.ts +++ b/src/services/chat/service.ts @@ -1,10 +1,12 @@ import type { Logger } from '../../utils/logger.js'; import type { + ConversationOutcomesRepository, MessagesRepository, RedditUsersRepository, SentMessageRecord, SuccessfulMessagesRepository, } from '../../database/repositories.js'; +import { recordConversationReply } from '../conversation-outcome-recorder.js'; import type { MediaService } from '../media.js'; import { ChatCache } from './cache.js'; import { @@ -151,6 +153,7 @@ export class ChatService { private messagesRepo?: MessagesRepository, private redditUsersRepo?: RedditUsersRepository, private accountAuth?: AccountAuthGate, + private outcomesRepo?: ConversationOutcomesRepository, ) { this.cache = cache ?? new ChatCache(logger); if (this.redditUsersRepo) { @@ -1007,6 +1010,7 @@ export class ChatService { (message) => !message.isSelf && (message.media?.length ?? 0) > 0, ); let performanceMessage: SentMessageRecord | undefined; + let firstAttributedReply = false; if (firstSelf && chat.roomId && this.messagesRepo) { try { @@ -1017,7 +1021,7 @@ export class ChatService { if (existing && hasPerformanceContext) { performanceMessage = existing; if (hasReply && !existing.gotResponse) { - this.messagesRepo.markGotResponse(existing.id); + firstAttributedReply = true; } if (hasReceivedImage && !existing.gotImage) { this.messagesRepo.markGotImage(existing.id); @@ -1028,19 +1032,26 @@ export class ChatService { } } - if (firstSelf && hasReply && performanceMessage && this.successfulMessagesRepo) { - if (!this.successfulMessagesRepo.existsByMessage(firstSelf.text)) { - const firstReply = chat.messages.find((m) => !m.isSelf); - try { - this.successfulMessagesRepo.insert({ - sentMessageId: performanceMessage.id, - originalMessage: firstSelf.text, - imageAnalysis: performanceMessage.postImageAnalysis ?? null, - postTitle: performanceMessage.postTitle ?? null, - responseSnippet: firstReply?.text.slice(0, 200) || null, - subreddit: performanceMessage.subreddit ?? null, - }); - this.logger.info(`[chat] stored successful chat example from ${chat.username}`); + if (firstSelf && hasReply && performanceMessage && this.successfulMessagesRepo && this.outcomesRepo && this.messagesRepo) { + const firstReply = chat.messages.find((m) => !m.isSelf); + const selfCount = chat.messages.filter((m) => m.isSelf).length; + const otherCount = chat.messages.length - selfCount; + try { + recordConversationReply( + performanceMessage, + firstReply?.text.slice(0, 200) ?? '', + { + messageCount: chat.messages.length, + reciprocal: selfCount >= 2 && otherCount >= 2, + }, + { + messages: this.messagesRepo, + successful: this.successfulMessagesRepo, + outcomes: this.outcomesRepo, + logger: this.logger, + }, + ); + if (firstAttributedReply) { try { this.newReplyListener?.({ conversationId: chat.roomId, @@ -1051,10 +1062,13 @@ export class ChatService { } catch (err) { this.logger.warn('[chat] newReplyListener threw:', err); } - } catch (err) { - this.logger.warn('[chat] failed to store successful chat example:', err); } + } catch (err) { + this.logger.warn('[chat] failed to record conversation outcome:', err); } + } else if (hasReply && performanceMessage && this.messagesRepo && !performanceMessage.gotResponse) { + this.messagesRepo.markGotResponse(performanceMessage.id); + this.logger.warn('[chat] response recorded without canonical outcome repository; excluded from learning'); } } diff --git a/src/services/conversation-outcome-recorder.ts b/src/services/conversation-outcome-recorder.ts new file mode 100644 index 0000000..073f55a --- /dev/null +++ b/src/services/conversation-outcome-recorder.ts @@ -0,0 +1,46 @@ +import type { + ConversationOutcomesRepository, + MessagesRepository, + SentMessageRecord, + SuccessfulMessagesRepository, +} from '../database/repositories.js'; +import type { Logger } from '../utils/logger.js'; +import { classifyConversationOutcome, type OutcomeContext, type ConversationOutcome } from './conversation-outcome.js'; + +export interface ConversationOutcomeDependencies { + messages: MessagesRepository; + successful: SuccessfulMessagesRepository; + outcomes: ConversationOutcomesRepository; + logger: Logger; +} + +/** Canonical reply/outcome path shared by inbox and Matrix ingestion. */ +export function recordConversationReply( + message: SentMessageRecord, + responseSnippet: string, + context: OutcomeContext, + deps: ConversationOutcomeDependencies, +): ConversationOutcome { + const outcome = classifyConversationOutcome(responseSnippet, context); + deps.messages.markGotResponse(message.id); + deps.outcomes.record(message.id, outcome, responseSnippet || null); + + if (outcome === 'boundary') { + deps.outcomes.suppressRecipient(message.recipient, 'boundary'); + } + + if (outcome === 'engaged' && !deps.successful.existsByMessage(message.message)) { + deps.successful.insert({ + sentMessageId: message.id, + originalMessage: message.message, + imageAnalysis: message.postImageAnalysis ?? null, + postTitle: message.postTitle ?? null, + responseSnippet: responseSnippet || null, + subreddit: message.subreddit ?? null, + }); + deps.logger.info(`Engaged response from u/${message.recipient}; stored for learning.`); + } else if (outcome !== 'engaged') { + deps.logger.info(`Response from u/${message.recipient} classified as ${outcome}; excluded from learning examples.`); + } + return outcome; +} diff --git a/src/services/conversation-outcome.ts b/src/services/conversation-outcome.ts index 945267a..71fa3e5 100644 --- a/src/services/conversation-outcome.ts +++ b/src/services/conversation-outcome.ts @@ -1,15 +1,25 @@ export const CONVERSATION_OUTCOMES = ['engaged', 'declined', 'boundary', 'upsell', 'unknown'] as const; export type ConversationOutcome = (typeof CONVERSATION_OUTCOMES)[number]; +export interface OutcomeContext { + /** Total human messages in the thread from both participants. */ + messageCount?: number; + /** True when both participants have sent at least two messages. */ + reciprocal?: boolean; +} + /** * Deterministic first-pass outcome classifier. It is deliberately conservative: * only replies with no decline, boundary, or sales signal become learning examples. */ -export function classifyConversationOutcome(reply: string): ConversationOutcome { +export function classifyConversationOutcome( + reply: string, + context: OutcomeContext = {}, +): ConversationOutcome { const text = reply.toLowerCase(); if (/\b(onlyfans|fansly|manyvids|link in bio|cash\.?app|venmo)\b/.test(text)) return 'upsell'; if (/\b(stop|leave me alone|do not message|don't message)\b/.test(text)) return 'boundary'; if (/\b(no thanks|not interested|nope|don't send|do not send)\b/.test(text)) return 'declined'; - if (text.trim().length > 0) return 'engaged'; + if (context.reciprocal && (context.messageCount ?? 0) >= 4 && text.trim().length > 0) return 'engaged'; return 'unknown'; } diff --git a/src/services/monitor.ts b/src/services/monitor.ts index 3c790f6..18943b2 100644 --- a/src/services/monitor.ts +++ b/src/services/monitor.ts @@ -42,7 +42,9 @@ export class MonitorService { // to two profile requests for every otherwise-valid post in the listing. const newestFirst = [...posts].sort((a, b) => b.created_utc - a.created_utc); for (const post of newestFirst) { - if (this.postsRepo.isProcessed(post.id)) { + // Older adapters used in integrations may only expose account-local + // history; production repositories provide the shared lookup. + if (this.postsRepo.isProcessedAnywhere?.(post.id) ?? this.postsRepo.isProcessed(post.id)) { continue; } diff --git a/src/services/processor.ts b/src/services/processor.ts index 3136744..95c384c 100644 --- a/src/services/processor.ts +++ b/src/services/processor.ts @@ -13,6 +13,11 @@ export interface ProcessedPost { detectedGender: DetectedGender; generatedMessage: string; strategyId: string | null; + experimentId: string | null; + promptVersion: string | null; + selectionReason: 'explore' | 'exploit' | null; + modelId: string; + sourceType: 'image' | 'chat'; toneId: ToneKey; scripted: boolean; } @@ -213,6 +218,11 @@ export class ProcessorService { detectedGender: analysis.detectedGender, generatedMessage: message, strategyId: selectedStrategy?.id || null, + experimentId: selectedStrategy?.experimentId ?? null, + promptVersion: selectedStrategy?.promptVersion ?? null, + selectionReason: selectedStrategy?.reason ?? null, + modelId: this.config.openrouter.imageAnalysisModel, + sourceType: 'image', toneId: tone.profile.id, scripted: tone.scripted !== null, }; @@ -290,6 +300,11 @@ export class ProcessorService { detectedGender: detectedGender ?? 'unknown', generatedMessage: message, strategyId: selectedStrategy?.id || null, + experimentId: selectedStrategy?.experimentId ?? null, + promptVersion: selectedStrategy?.promptVersion ?? null, + selectionReason: selectedStrategy?.reason ?? null, + modelId: this.config.openrouter.chatModel, + sourceType: 'chat', toneId: tone.profile.id, scripted: tone.scripted !== null, }; diff --git a/src/services/reddit-account-runtime-builder.ts b/src/services/reddit-account-runtime-builder.ts index 99359b2..96aceaf 100644 --- a/src/services/reddit-account-runtime-builder.ts +++ b/src/services/reddit-account-runtime-builder.ts @@ -12,7 +12,8 @@ import { RecipientHistoryRepository, } from '../database/repositories.js'; import { AccountContentRepository } from '../database/account-repositories.js'; -import { RedditApiClient } from '../reddit/api.js'; +import { RedditApiClient, RedditRateLimitError, type RedditPost } from '../reddit/api.js'; +import { RedditRequestCoordinator } from '../reddit/request-coordinator.js'; import { OpenRouterClient } from '../ai/openrouter.js'; import { MonitorService } from './monitor.js'; import { ProcessorService, isSkip } from './processor.js'; @@ -52,8 +53,9 @@ export function createConcreteRedditAccountRuntimeFactory( const recipientDeduplicator = new RecipientDeduplicator( new RecipientHistoryRepository(options.db), ); + const redditRequestCoordinator = new RedditRequestCoordinator(); return new RedditAccountRuntimeFactory( - (session) => buildRuntime(options, session, recipientDeduplicator), + (session) => buildRuntime(options, session, recipientDeduplicator, redditRequestCoordinator), ); } @@ -61,6 +63,7 @@ async function buildRuntime( { config, logger, db, isBotRunning = () => true }: ConcreteRuntimeFactoryOptions, session: VerifiedAccountSession, recipientDeduplicator: RecipientDeduplicator, + redditRequestCoordinator: RedditRequestCoordinator, ): Promise { const browserProfileDir = accountBrowserProfileDir( path.resolve(process.cwd(), config.browser.sessionDir), @@ -107,7 +110,7 @@ async function buildRuntime( const accountContentRepo = new AccountContentRepository(db); const activityLogRepo = new ActivityLogRepository(db, session.accountId); - const redditApi = new RedditApiClient(accountConfig, logger); + const redditApi = new RedditApiClient(accountConfig, logger, redditRequestCoordinator); redditApi.setBrowserContext(browserClient.getContext()!); const chatPage = await browserClient.getContext()!.newPage(); const aiClient = new OpenRouterClient(accountConfig, logger); @@ -135,7 +138,7 @@ async function buildRuntime( const chatService = new ChatService( matrixClient, logger, successfulMessagesRepo, mediaService, new MatrixTokenRefresher(), usernameResolver, chatCache, - messagesRepo, redditUsersRepo, accountAuth, + messagesRepo, redditUsersRepo, accountAuth, conversationOutcomesRepo, ); await chatService.loadCache(); const mediaSyncService = new MediaSyncService(chatService, logger, accountConfig.media.syncIntervalMs, accountAuth); @@ -227,23 +230,23 @@ class AccountRuntimeLoop { nextResponseCheckAt = Date.now() + responseInterval; await this.resources.responseChecker.checkForResponses(); } - if (!this.resources.chatService.getInvitationCooldown()) { - await this.processPosts(signal); - } await delay(this.config.reddit.pollIntervalMs, signal); } catch (error) { if (signal.aborted) return; + if (error instanceof RedditRateLimitError) { + await delay(error.retryAfterMs + 1_000, signal); + continue; + } this.logger.error(`Account runtime loop failed for ${this.resources.accountAuth.getSnapshot().redditUsername}:`, error); await delay(60_000, signal); } } } - private async processPosts(signal: AbortSignal): Promise { - const { monitorService, postsRepo, messagesRepo, redditApi, processorService, rateLimiter, chatService, responseChecker } = this.resources; - const posts = await monitorService.getUnprocessedPosts(); - for (const post of posts) { - if (signal.aborted) return; + async dispatchPost(post: RedditPost, signal?: AbortSignal): Promise { + const { postsRepo, messagesRepo, redditApi, processorService, rateLimiter, chatService, responseChecker } = this.resources; + if (signal?.aborted) return; + try { const subreddit = this.config.reddit.subreddits.find((entry) => entry.name.toLowerCase() === post.subreddit.toLowerCase()); const isChat = subreddit?.type === 'chat'; const imageUrl = isChat ? null : redditApi.getImageUrl(post); @@ -251,25 +254,26 @@ class AccountRuntimeLoop { id: post.id, subreddit: post.subreddit, author: post.author, title: post.title, postUrl: redditApi.getPostUrl(post), imageUrl, processedAt: Date.now(), }); - try { - if ( - this.config.reddit.filterSellers - && (this.config.reddit.sellerKeywords.length > 0 || this.config.reddit.sellerDomains.length > 0) - && await redditApi.isSellerProfile(post.author, this.config.reddit.sellerKeywords, this.config.reddit.sellerDomains) - ) { - postsRepo.setError(post.id, 'Seller profile detected'); - this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'skipped', message: `Skipped post by u/${post.author} (seller profile)`, recipient: post.author }); - continue; - } + // Seller screening is performed once by the shared MonitorService before + // assignment. Do not repeat its profile request for every recipient. processorService.setSuccessfulExamples(responseChecker.getSuccessfulExamples(3, post.subreddit)); const processed = await processorService.processPost(post); if (isSkip(processed)) { postsRepo.setError(post.id, processed.skipReason); this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'skipped', message: `Skipped post by u/${post.author} (${processed.skipReason})`, recipient: post.author }); - continue; + return; } postsRepo.updateAnalysis(post.id, processed.imageAnalysis, processed.generatedMessage); await rateLimiter.throttle(async () => { + if (this.config.browser.dryRun) { + const reason = 'Dry run: message prepared but not sent'; + postsRepo.setError(post.id, reason); + this.resources.activityLogRepo.insert({ + timestamp: Date.now(), type: 'skipped', + message: `${reason} to u/${post.author}`, recipient: post.author, + }); + return false; + } if (!this.recipientDeduplicator.tryReserve(post.author)) { postsRepo.setError(post.id, 'User already contacted across accounts'); this.resources.activityLogRepo.insert({ @@ -295,6 +299,9 @@ class AccountRuntimeLoop { postId: post.id, recipient: post.author, subject: post.author, message: processed.generatedMessage, sentAt: Date.now(), strategyId: processed.strategyId, chatRoomId: chat.roomId, + experimentId: processed.experimentId, promptVersion: processed.promptVersion, + modelId: processed.modelId, selectionReason: processed.selectionReason, + sourceType: processed.sourceType, }); this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'sent', message: `Sent message to u/${post.author} (r/${post.subreddit})`, @@ -303,12 +310,11 @@ class AccountRuntimeLoop { void chatService.refreshTopChats(5); return true; }, (success) => success, signal); - } catch (error) { - if (signal.aborted) return; - postsRepo.setError(post.id, error instanceof Error ? error.message : String(error)); - this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'error', message: `Failed processing post by u/${post.author}`, recipient: post.author }); - this.logger.error(`Account runtime failed processing post ${post.id}:`, error); - } + } catch (error) { + if (signal?.aborted) return; + postsRepo.setError(post.id, error instanceof Error ? error.message : String(error)); + this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'error', message: `Failed processing post by u/${post.author}`, recipient: post.author }); + this.logger.error(`Account runtime failed processing post ${post.id}:`, error); } } } diff --git a/src/services/reddit-account-runtime.ts b/src/services/reddit-account-runtime.ts index 82ca537..66fbc4d 100644 --- a/src/services/reddit-account-runtime.ts +++ b/src/services/reddit-account-runtime.ts @@ -29,6 +29,7 @@ import type { MediaSyncService } from './media-sync.js'; import type { MonitorService } from './monitor.js'; import type { ProcessorService } from './processor.js'; import type { ResponseCheckerService } from './response-checker.js'; +import type { RedditPost } from '../reddit/api.js'; /** Credentials passed to a factory. They must never be exposed by list/snapshot APIs. */ export interface VerifiedAccountSession { @@ -80,6 +81,8 @@ export interface RedditAccountRuntimeLifecycle { start(): void | Promise; /** Stop and await this account's schedules/loop, then close its browser. */ stop(): void | Promise; + /** Process a post assigned by the shared discovery scheduler. */ + dispatchPost?(post: RedditPost, signal?: AbortSignal): Promise; } export type RedditAccountRuntimeState = @@ -172,6 +175,24 @@ export class RedditAccountRuntime { }; } + async canAcceptPost(): Promise { + if (this.state !== 'running') return false; + if (!this.resources.accountAuth.isFullyAuthenticated() + && !(await this.resources.accountAuth.ensureFullyAuthenticated())) return false; + if (this.resources.chatService.getInvitationCooldown()) return false; + return this.resources.rateLimiter.getTimers().nextMessageAvailableAt === null; + } + + dispatchPost(post: RedditPost, signal?: AbortSignal): Promise { + if (this.state !== 'running') { + throw new Error(`Cannot assign a post to ${this.session.redditUsername}: runtime is not running`); + } + if (!this.lifecycle.dispatchPost) { + throw new Error(`Runtime ${this.session.redditUsername} does not support shared post dispatch`); + } + return this.lifecycle.dispatchPost(post, signal); + } + private enqueue(operation: () => Promise): Promise { const result = this.transition.then(operation, operation); this.transition = result.catch(() => undefined); diff --git a/src/services/response-checker.ts b/src/services/response-checker.ts index 4d21b06..e3aa085 100644 --- a/src/services/response-checker.ts +++ b/src/services/response-checker.ts @@ -2,7 +2,7 @@ import type { Page } from 'playwright'; import type { Logger } from '../utils/logger.js'; import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord, ConversationOutcomesRepository } from '../database/repositories.js'; import type { PostsRepository } from '../database/repositories.js'; -import { classifyConversationOutcome } from './conversation-outcome.js'; +import { recordConversationReply } from './conversation-outcome-recorder.js'; import { denyAccountAuth, type AccountAuthGate } from './account-auth-context.js'; export interface ResponseCheckResult { @@ -68,25 +68,28 @@ export class ResponseCheckerService { continue; } - this.messagesRepo.markGotResponse(message.id); - - // Get the original post info const post = message.postId ? this.postsRepo.getById(message.postId) : undefined; - - const outcome = classifyConversationOutcome(result.responseSnippet ?? ''); - this.outcomesRepo?.record(message.id, outcome, result.responseSnippet ?? null); - if (outcome === 'engaged') { - this.successfulMessagesRepo.insert({ - sentMessageId: message.id, - originalMessage: message.message, - imageAnalysis: post?.imageAnalysis || null, - postTitle: post?.title || null, - responseSnippet: result.responseSnippet || null, - subreddit: post?.subreddit || null, - }); - this.logger.info(`Engaged response from u/${message.recipient}; stored for learning.`); + const attributedMessage = { + ...message, + subreddit: message.subreddit ?? post?.subreddit, + postTitle: message.postTitle ?? post?.title, + postImageAnalysis: message.postImageAnalysis ?? post?.imageAnalysis ?? undefined, + }; + if (this.outcomesRepo) { + recordConversationReply( + attributedMessage, + result.responseSnippet ?? '', + { messageCount: 2, reciprocal: false }, + { + messages: this.messagesRepo, + successful: this.successfulMessagesRepo, + outcomes: this.outcomesRepo, + logger: this.logger, + }, + ); } else { - this.logger.info(`Response from u/${message.recipient} classified as ${outcome}; excluded from learning examples.`); + this.messagesRepo.markGotResponse(message.id); + this.logger.warn('Outcome repository unavailable; response recorded but excluded from learning.'); } } else { // Just mark as checked diff --git a/src/services/strategy-selector.ts b/src/services/strategy-selector.ts index 3219c41..60a90e6 100644 --- a/src/services/strategy-selector.ts +++ b/src/services/strategy-selector.ts @@ -6,12 +6,16 @@ export interface SelectedStrategy { id: string; promptOverride: string; reason: 'exploit' | 'explore'; + experimentId?: string; + promptVersion?: string; } export class StrategySelector { private variants: Config['strategies']['variants']; private explorationRate: number; private minSamples: number; + private responseWindowMs: number; + private section: 'strategies' | 'chatStrategies'; constructor( private config: Config, @@ -19,10 +23,12 @@ export class StrategySelector { private logger: Logger, section: 'strategies' | 'chatStrategies' = 'strategies', ) { + this.section = section; const cfg = config[section]; this.variants = cfg.variants; this.explorationRate = cfg.explorationRate; this.minSamples = cfg.minSamples; + this.responseWindowMs = cfg.responseWindowHours * 60 * 60 * 1000; } selectStrategy(): SelectedStrategy | null { @@ -32,13 +38,13 @@ export class StrategySelector { // Exploration: random strategy if (Math.random() < this.explorationRate) { - const variant = this.variants[Math.floor(Math.random() * this.variants.length)]; + const variant = this.weightedChoice(this.variants); this.logger.debug(`Strategy selected (explore): ${variant.id}`); - return { id: variant.id, promptOverride: variant.promptOverride, reason: 'explore' }; + return this.selected(variant, 'explore'); } // Exploitation: pick the best-performing strategy - const stats = this.messagesRepo.getStrategyStats(); + const stats = this.messagesRepo.getStrategyStats(Date.now() - this.responseWindowMs); const statsMap = new Map(stats.map(s => [s.strategyId, s])); // Find strategies with insufficient samples - prioritize exploring them @@ -48,9 +54,9 @@ export class StrategySelector { }); if (unexplored.length > 0) { - const variant = unexplored[Math.floor(Math.random() * unexplored.length)]; + const variant = this.weightedChoice(unexplored); this.logger.debug(`Strategy selected (under-sampled): ${variant.id}`); - return { id: variant.id, promptOverride: variant.promptOverride, reason: 'explore' }; + return this.selected(variant, 'explore'); } // All strategies have enough samples - pick the best response rate @@ -66,11 +72,11 @@ export class StrategySelector { } this.logger.debug(`Strategy selected (exploit, ${bestRate}% response rate): ${bestVariant.id}`); - return { id: bestVariant.id, promptOverride: bestVariant.promptOverride, reason: 'exploit' }; + return this.selected(bestVariant, 'exploit'); } getStats(): (StrategyStats & { configured: boolean })[] { - const dbStats = this.messagesRepo.getStrategyStats(); + const dbStats = this.messagesRepo.getStrategyStats(Date.now() - this.responseWindowMs); const statsMap = new Map(dbStats.map(s => [s.strategyId, s])); // Include all configured variants, even if they have no data yet @@ -102,4 +108,30 @@ export class StrategySelector { hasStrategies(): boolean { return this.variants.length > 0; } + + private selected( + variant: Config['strategies']['variants'][number], + reason: 'explore' | 'exploit', + ): SelectedStrategy { + const promptVersion = createHash('sha256').update(variant.promptOverride).digest('hex').slice(0, 12); + return { + id: variant.id, + promptOverride: variant.promptOverride, + reason, + promptVersion, + experimentId: `${this.section}:${variant.id}:${promptVersion}`, + }; + } + + private weightedChoice(variants: readonly T[]): T { + const total = variants.reduce((sum, variant) => sum + Math.max(0, variant.weight ?? 1), 0); + if (total <= 0) return variants[Math.floor(Math.random() * variants.length)]; + let cursor = Math.random() * total; + for (const variant of variants) { + cursor -= Math.max(0, variant.weight ?? 1); + if (cursor < 0) return variant; + } + return variants[variants.length - 1]; + } } +import { createHash } from 'node:crypto'; diff --git a/src/web/agent-api-contracts.ts b/src/web/agent-api-contracts.ts new file mode 100644 index 0000000..e7eb278 --- /dev/null +++ b/src/web/agent-api-contracts.ts @@ -0,0 +1,134 @@ +import { z } from 'zod'; + +export const AgentScopeSchema = z.enum(['read', 'control', 'admin']); + +export const HealthResponseSchema = z.strictObject({ + status: z.literal('ok'), + service: z.literal('reddit-bot'), + timestamp: z.string().datetime(), + uptimeSeconds: z.number().nonnegative(), +}); + +export const ComponentReadinessSchema = z.strictObject({ + ready: z.boolean(), + detail: z.string(), +}); + +export const ReadinessResponseSchema = z.strictObject({ + ready: z.boolean(), + status: z.enum(['ready', 'degraded', 'unavailable']), + checkedAt: z.string().datetime(), + components: z.record(z.string(), ComponentReadinessSchema), +}); + +export const AgentAccountSchema = z.strictObject({ + accountId: z.string(), + redditUsername: z.string(), + runtimeState: z.string(), + authenticated: z.boolean(), + failure: z.string().nullable(), + updatedAt: z.number(), +}); + +export const AgentSyncSummarySchema = z.strictObject({ + chat: z.strictObject({ + syncing: z.boolean(), + kind: z.enum(['full', 'delta']).nullable(), + lastFullSyncAt: z.number().nullable(), + lastDeltaSyncAt: z.number().nullable(), + cachedChats: z.number(), + }), + media: z.strictObject({ + syncing: z.boolean(), + lastSyncAt: z.number().nullable(), + nextSyncAt: z.number().nullable(), + lastError: z.string().nullable(), + }), +}); + +export const RecentErrorSchema = z.strictObject({ + timestamp: z.number(), + message: z.string(), + data: z.unknown().optional(), +}); + +export const OverviewResponseSchema = z.strictObject({ + generatedAt: z.string().datetime(), + status: z.enum(['operational', 'degraded', 'stopped']), + bot: z.strictObject({ + running: z.boolean(), + dryRun: z.boolean(), + authenticated: z.boolean(), + messagesThisHour: z.number(), + maxMessagesPerHour: z.number(), + invitationCooldown: z.strictObject({ + until: z.number(), + reason: z.string(), + }).nullable(), + }), + accounts: z.array(AgentAccountSchema), + sync: AgentSyncSummarySchema, + recentErrors: z.array(RecentErrorSchema), + links: z.strictObject({ + capabilities: z.string(), + openapi: z.string(), + events: z.string(), + jobs: z.string(), + }), +}); + +export const ControlRequestSchema = z.strictObject({ running: z.boolean() }); +export const ControlResponseSchema = z.strictObject({ + success: z.boolean(), + running: z.boolean(), + authenticated: z.boolean(), + changed: z.boolean(), + message: z.string(), +}); + +export const AgentJobRequestSchema = z.discriminatedUnion('type', [ + z.strictObject({ + type: z.literal('chat-sync'), + kind: z.enum(['delta', 'full']).default('delta'), + accountId: z.string().min(1).optional(), + }), + z.strictObject({ + type: z.literal('media-sync'), + kind: z.enum(['delta', 'full']).default('delta'), + accountId: z.string().min(1).optional(), + }), +]); + +export const AgentJobSchema = z.strictObject({ + id: z.string().uuid(), + type: z.enum(['chat-sync', 'media-sync']), + kind: z.enum(['delta', 'full']), + accountId: z.string().optional(), + status: z.enum(['queued', 'running', 'succeeded', 'failed']), + createdAt: z.string().datetime(), + startedAt: z.string().datetime().nullable(), + completedAt: z.string().datetime().nullable(), + result: z.unknown().nullable(), + error: z.string().nullable(), +}); + +export const CapabilitiesResponseSchema = z.strictObject({ + apiVersion: z.literal('v1'), + authentication: z.strictObject({ + schemes: z.array(z.string()), + agentScopes: z.array(AgentScopeSchema), + }), + operations: z.array(z.strictObject({ + operationId: z.string(), + method: z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']), + path: z.string(), + summary: z.string(), + access: AgentScopeSchema, + mutates: z.boolean(), + streaming: z.boolean(), + })), +}); + +export type AgentJobRequest = z.infer; +export type AgentJob = z.infer; +export type ControlResponse = z.infer; diff --git a/src/web/agent-job-manager.ts b/src/web/agent-job-manager.ts new file mode 100644 index 0000000..aea566c --- /dev/null +++ b/src/web/agent-job-manager.ts @@ -0,0 +1,65 @@ +import { randomUUID } from 'node:crypto'; +import type { AgentJob, AgentJobRequest } from './agent-api-contracts.js'; + +const MAX_JOBS = 100; + +export type AgentJobExecutor = (request: AgentJobRequest) => Promise; + +export class AgentJobManager { + private readonly jobs = new Map(); + + constructor(private readonly execute: AgentJobExecutor) {} + + create(request: AgentJobRequest): AgentJob { + const job: AgentJob = { + id: randomUUID(), + type: request.type, + kind: request.kind, + ...(request.accountId ? { accountId: request.accountId } : {}), + status: 'queued', + createdAt: new Date().toISOString(), + startedAt: null, + completedAt: null, + result: null, + error: null, + }; + this.jobs.set(job.id, job); + this.trim(); + queueMicrotask(() => void this.run(job.id, request)); + return { ...job }; + } + + get(id: string): AgentJob | undefined { + const job = this.jobs.get(id); + return job ? { ...job } : undefined; + } + + list(limit = 20): AgentJob[] { + const bounded = Math.max(1, Math.min(100, Number.isInteger(limit) ? limit : 20)); + return [...this.jobs.values()].slice(-bounded).reverse().map((job) => ({ ...job })); + } + + private async run(id: string, request: AgentJobRequest): Promise { + const job = this.jobs.get(id); + if (!job) return; + job.status = 'running'; + job.startedAt = new Date().toISOString(); + try { + job.result = await this.execute(request); + job.status = 'succeeded'; + } catch (error) { + job.status = 'failed'; + job.error = error instanceof Error ? error.message : String(error); + } finally { + job.completedAt = new Date().toISOString(); + } + } + + private trim(): void { + while (this.jobs.size > MAX_JOBS) { + const oldest = this.jobs.keys().next().value as string | undefined; + if (!oldest) return; + this.jobs.delete(oldest); + } + } +} diff --git a/src/web/api-catalog.ts b/src/web/api-catalog.ts new file mode 100644 index 0000000..f27cded --- /dev/null +++ b/src/web/api-catalog.ts @@ -0,0 +1,177 @@ +import { z, type ZodType } from 'zod'; +import { + AgentJobRequestSchema, + AgentJobSchema, + CapabilitiesResponseSchema, + ControlRequestSchema, + ControlResponseSchema, + HealthResponseSchema, + OverviewResponseSchema, + ReadinessResponseSchema, +} from './agent-api-contracts.js'; + +export type ApiAccess = 'read' | 'control' | 'admin'; +export type ApiMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + +export interface ApiOperation { + operationId: string; + method: ApiMethod; + path: string; + summary: string; + access: ApiAccess; + mutates: boolean; + streaming?: boolean; + requestSchema?: ZodType; + responseSchema?: ZodType; + successStatus?: number; + tags: string[]; +} + +const operation = ( + operationId: string, + method: ApiMethod, + path: string, + summary: string, + tags: string[], + options: Partial> = {}, +): ApiOperation => ({ + operationId, + method, + path, + summary, + tags, + access: options.access ?? (method === 'GET' ? 'read' : 'admin'), + mutates: options.mutates ?? method !== 'GET', + streaming: options.streaming ?? false, + requestSchema: options.requestSchema, + responseSchema: options.responseSchema, + successStatus: options.successStatus, +}); + +/** + * Machine-readable catalogue for every HTTP operation exposed by the host. + * Legacy dashboard routes remain here during the v1 migration so discovery + * never depends on scraping Express source or stale prose documentation. + */ +export const API_OPERATIONS: readonly ApiOperation[] = [ + operation('health', 'GET', '/healthz', 'Process liveness', ['Operations'], { responseSchema: HealthResponseSchema }), + operation('getOverview', 'GET', '/api/v1/overview', 'Consolidated operational snapshot', ['Agent'], { responseSchema: OverviewResponseSchema }), + operation('getReadiness', 'GET', '/api/v1/readyz', 'Component readiness', ['Agent'], { responseSchema: ReadinessResponseSchema }), + operation('getCapabilities', 'GET', '/api/v1/capabilities', 'Discover API operations and safety attributes', ['Agent'], { responseSchema: CapabilitiesResponseSchema }), + operation('getOpenApi', 'GET', '/api/openapi.json', 'OpenAPI 3.1 description', ['Discovery']), + operation('getControlStatus', 'GET', '/api/v1/control', 'Get bot running state', ['Control'], { responseSchema: ControlResponseSchema }), + operation('setControl', 'POST', '/api/v1/control', 'Set bot running state', ['Control'], { access: 'control', requestSchema: ControlRequestSchema, responseSchema: ControlResponseSchema }), + operation('startBotV1', 'POST', '/api/v1/control/start', 'Start bot processing', ['Control'], { access: 'control', responseSchema: ControlResponseSchema }), + operation('stopBotV1', 'POST', '/api/v1/control/stop', 'Stop bot processing', ['Control'], { access: 'control', responseSchema: ControlResponseSchema }), + operation('listJobs', 'GET', '/api/v1/jobs', 'List recent asynchronous jobs', ['Jobs'], { responseSchema: z.array(AgentJobSchema) }), + operation('getJob', 'GET', '/api/v1/jobs/{id}', 'Get asynchronous job state', ['Jobs'], { responseSchema: AgentJobSchema }), + operation('createJob', 'POST', '/api/v1/jobs', 'Start chat or media synchronisation', ['Jobs'], { access: 'control', requestSchema: AgentJobRequestSchema, responseSchema: AgentJobSchema, successStatus: 202 }), + + operation('getStats', 'GET', '/api/stats', 'Get bot statistics', ['Dashboard']), + operation('getActivity', 'GET', '/api/activity', 'Get recent activity', ['Dashboard']), + operation('getPostsByAuthor', 'GET', '/api/posts/by-author/{username}', 'Get processed posts by author', ['Dashboard']), + operation('getMessages', 'GET', '/api/messages', 'Get recent sent messages', ['Dashboard']), + operation('getSuccessful', 'GET', '/api/successful', 'Get successful message examples', ['Dashboard']), + operation('getChats', 'GET', '/api/chats', 'Get active chats', ['Chats']), + operation('getChatSync', 'GET', '/api/chats/sync', 'Get chat sync state', ['Chats']), + operation('triggerDeltaChatSync', 'POST', '/api/chats/sync/delta', 'Trigger incremental chat sync', ['Chats']), + operation('triggerFullChatSync', 'POST', '/api/chats/sync/full', 'Trigger full chat sync', ['Chats']), + operation('fetchAllChats', 'POST', '/api/chats/fetch-all', 'Fetch all chats synchronously', ['Chats']), + operation('getConversations', 'GET', '/api/conversations', 'List conversations', ['Conversations']), + operation('getConversation', 'GET', '/api/conversations/{id}', 'Get conversation detail', ['Conversations']), + operation('suggestReply', 'POST', '/api/conversations/{id}/suggest-reply', 'Generate a reply suggestion', ['Conversations']), + operation('suggestMediaAsk', 'POST', '/api/conversations/{id}/suggest-media-ask', 'Generate a media request suggestion', ['Conversations']), + operation('sendReply', 'POST', '/api/conversations/{id}/send-reply', 'Send a conversation reply', ['Conversations']), + operation('startConversation', 'POST', '/api/conversations/start', 'Start a new conversation', ['Conversations']), + operation('getMedia', 'GET', '/api/media', 'List indexed private media', ['Media']), + operation('getMediaSync', 'GET', '/api/media/sync', 'Get media sync state', ['Media']), + operation('triggerMediaSync', 'POST', '/api/media/sync', 'Trigger media sync', ['Media']), + operation('triggerFullMediaSync', 'POST', '/api/media/sync/full', 'Trigger full media sync', ['Media']), + operation('startBot', 'POST', '/api/start', 'Start bot processing (legacy)', ['Control']), + operation('stopBot', 'POST', '/api/stop', 'Stop bot processing (legacy)', ['Control']), + operation('getTimers', 'GET', '/api/timers', 'Get rate-limit timers', ['Analytics']), + operation('getSubredditAnalytics', 'GET', '/api/analytics/by-subreddit', 'Get performance by subreddit', ['Analytics']), + operation('getStrategyAnalytics', 'GET', '/api/analytics/by-strategy', 'Get performance by strategy', ['Analytics']), + operation('getFunnelAnalytics', 'GET', '/api/analytics/funnel', 'Get mature message outcome funnel', ['Analytics']), + operation('getAnalyticsTrend', 'GET', '/api/analytics/trend', 'Get daily performance trend', ['Analytics']), + operation('getConfig', 'GET', '/api/config', 'Get non-secret application configuration', ['Configuration']), + operation('updateConfig', 'PUT', '/api/config', 'Update application configuration', ['Configuration']), + operation('getLoginStatus', 'GET', '/api/login/status', 'Get Reddit session state', ['Accounts']), + operation('importSession', 'POST', '/api/login/session-import', 'Import Reddit and Matrix credentials', ['Accounts']), + operation('listAccounts', 'GET', '/api/accounts', 'List Reddit accounts', ['Accounts']), + operation('activateAccount', 'POST', '/api/accounts/{accountId}/activate', 'Activate a Reddit account', ['Accounts']), + operation('streamLogs', 'GET', '/api/logs/stream', 'Stream legacy log events', ['Events'], { streaming: true }), + operation('streamEvents', 'GET', '/api/events/stream', 'Stream typed application events', ['Events'], { streaming: true }), + operation('getLogs', 'GET', '/api/logs', 'Get recent structured logs', ['Events']), + operation('getCookieDiagnostics', 'GET', '/api/debug/cookies', 'Get redacted cookie diagnostics when enabled', ['Debug'], { access: 'admin' }), + operation('checkResponses', 'POST', '/api/debug/check-responses', 'Run response check when enabled', ['Debug']), +]; + +function jsonSchema(schema: ZodType | undefined): Record | undefined { + return schema ? z.toJSONSchema(schema, { target: 'draft-7' }) as Record : undefined; +} + +export function createOpenApiDocument(): Record { + const paths: Record> = {}; + for (const item of API_OPERATIONS) { + const path = paths[item.path] ?? {}; + const request = jsonSchema(item.requestSchema); + const response = jsonSchema(item.responseSchema) ?? {}; + path[item.method.toLowerCase()] = { + operationId: item.operationId, + summary: item.summary, + tags: item.tags, + security: item.path === '/healthz' ? [] : [{ bearerAuth: [] }, { cookieAuth: [] }], + 'x-access': item.access, + 'x-mutates': item.mutates, + 'x-streaming': item.streaming, + ...(request ? { requestBody: { required: true, content: { 'application/json': { schema: request } } } } : {}), + responses: { + [String(item.successStatus ?? 200)]: { + description: 'Successful response', + content: item.streaming + ? { 'text/event-stream': { schema: { type: 'string' } } } + : { 'application/json': { schema: response } }, + }, + '401': { description: 'Authentication required' }, + '403': { description: 'Credential lacks the required access' }, + }, + }; + paths[item.path] = path; + } + return { + openapi: '3.1.0', + info: { + title: 'RedditBot API', + version: '1.0.0', + description: 'Dashboard and agent API. Prefer /api/v1 operations for automation.', + }, + servers: [{ url: '/' }], + paths, + components: { + securitySchemes: { + bearerAuth: { type: 'http', scheme: 'bearer' }, + cookieAuth: { type: 'apiKey', in: 'cookie', name: 'reddit_bot_dashboard_session' }, + }, + }, + }; +} + +export function capabilities() { + return { + apiVersion: 'v1' as const, + authentication: { + schemes: ['Bearer token', 'HttpOnly dashboard session'], + agentScopes: ['read', 'control', 'admin'] as const, + }, + operations: API_OPERATIONS.map(({ operationId, method, path, summary, access, mutates, streaming }) => ({ + operationId, + method, + path, + summary, + access, + mutates, + streaming: Boolean(streaming), + })), + }; +} diff --git a/src/web/auth.ts b/src/web/auth.ts index 6abe99c..634a38f 100644 --- a/src/web/auth.ts +++ b/src/web/auth.ts @@ -5,6 +5,11 @@ import { z } from 'zod'; const SessionRequestSchema = z.object({ token: z.string().min(1).max(4096) }).strict(); const SESSION_COOKIE = 'reddit_bot_dashboard_session'; +export interface AgentTokenOptions { + readToken?: string; + controlToken?: string; +} + function tokenDigest(token: string): Buffer { return createHash('sha256').update(token, 'utf8').digest(); } @@ -34,13 +39,23 @@ function requestToken(authorization: string | undefined): string | undefined { return bearer?.[1]; } -export function createDashboardAuth(token: string): RequestHandler { +export function createDashboardAuth(token: string, agentTokens: AgentTokenOptions = {}): RequestHandler { if (!token.trim()) throw new Error('A non-empty dashboard authentication token is required'); return (req, res, next) => { if (req.path === '/auth/session' || req.path === '/auth/login') return next(); const supplied = requestToken(req.header('authorization')) ?? parseCookies(req.header('cookie'))[SESSION_COOKIE]; if (matchesToken(supplied, token)) return next(); + const readAgent = agentTokens.readToken && matchesToken(supplied, agentTokens.readToken); + const controlAgent = agentTokens.controlToken && matchesToken(supplied, agentTokens.controlToken); + if (readAgent || controlAgent) { + const safeRead = req.method === 'GET' || req.method === 'HEAD'; + const versionedControl = controlAgent && req.path.startsWith('/api/v1/') && !safeRead; + const permittedRead = safeRead && !req.path.startsWith('/api/debug/'); + if (permittedRead || versionedControl) return next(); + res.status(403).json({ error: 'Agent credential lacks the required access' }); + return; + } if (!req.path.startsWith('/api') && !req.path.startsWith('/media')) { res.redirect(302, '/auth/login'); return; diff --git a/src/web/bot-control.ts b/src/web/bot-control.ts new file mode 100644 index 0000000..f3f99e8 --- /dev/null +++ b/src/web/bot-control.ts @@ -0,0 +1,62 @@ +import type { WebContext } from './context.js'; +import type { ControlResponse } from './agent-api-contracts.js'; + +export function botControlStatus(ctx: WebContext): ControlResponse { + const running = ctx.getBotRunning(); + return { + success: true, + running, + authenticated: hasAuthenticatedAccount(ctx), + changed: false, + message: running ? 'Bot is running' : 'Bot is stopped', + }; +} + +export function setBotRunning( + ctx: WebContext, + running: boolean, + actor: 'dashboard' | 'agent' = 'dashboard', +): ControlResponse { + const authenticated = hasAuthenticatedAccount(ctx); + if (running && !authenticated) { + ctx.logger.warn(`Bot start blocked for ${actor} until full account authentication`); + return { + success: false, + running: false, + authenticated: false, + changed: false, + message: 'Bot start requires a fully authenticated Reddit account', + }; + } + + const current = ctx.getBotRunning(); + if (current === running) { + return { + success: true, + running, + authenticated, + changed: false, + message: running ? 'Bot was already running' : 'Bot was already stopped', + }; + } + + ctx.setBotRunning(running); + ctx.activity.push(running ? 'sent' : 'skipped', `Bot ${running ? 'started' : 'stopped'} by ${actor}`); + ctx.logger.info(`Bot ${running ? 'started' : 'stopped'} via ${actor}`); + ctx.broadcaster.dashboardEvents.bot({ running, dryRun: ctx.config.browser.dryRun }); + return { + success: true, + running, + authenticated, + changed: true, + message: `Bot ${running ? 'started' : 'stopped'}`, + }; +} + +function hasAuthenticatedAccount(ctx: WebContext): boolean { + if (ctx.services.accountAuth?.isFullyAuthenticated()) return true; + return (ctx.services.accountRuntimes?.list() ?? []).some((snapshot) => { + if (snapshot.state !== 'running') return false; + return ctx.services.accountRuntimes?.get(snapshot.accountId)?.resources.accountAuth.isFullyAuthenticated() ?? false; + }); +} diff --git a/src/web/log-broadcaster.ts b/src/web/log-broadcaster.ts index e2b024a..2ec9d58 100644 --- a/src/web/log-broadcaster.ts +++ b/src/web/log-broadcaster.ts @@ -89,9 +89,15 @@ export class LogBroadcaster { /** Express handler for `/api/logs?limit=N`. */ recent = (req: Request, res: Response): void => { const limit = parseInt(req.query.limit as string) || 100; - res.json(this.entries.slice(-limit)); + res.json(this.getRecent(limit)); }; + /** Read-only snapshot for operational APIs without routing through HTTP. */ + getRecent(limit = 100): LogEntry[] { + const bounded = Number.isInteger(limit) ? Math.max(0, Math.min(limit, MAX_LOGS)) : 100; + return this.entries.slice(-bounded).map((entry) => ({ ...entry })); + } + closeAll(): void { for (const client of this.clients) { try { diff --git a/src/web/routes/agent.ts b/src/web/routes/agent.ts new file mode 100644 index 0000000..42b2922 --- /dev/null +++ b/src/web/routes/agent.ts @@ -0,0 +1,229 @@ +import { Router } from 'express'; +import type { WebContext } from '../context.js'; +import { AgentJobRequestSchema, ControlRequestSchema } from '../agent-api-contracts.js'; +import { AgentJobManager } from '../agent-job-manager.js'; +import { botControlStatus, setBotRunning } from '../bot-control.js'; +import { capabilities, createOpenApiDocument } from '../api-catalog.js'; + +export function createHealthRouter(startedAt = Date.now()): Router { + const router = Router(); + router.get('/healthz', (_req, res) => { + res.json({ + status: 'ok', + service: 'reddit-bot', + timestamp: new Date().toISOString(), + uptimeSeconds: Math.max(0, Math.floor((Date.now() - startedAt) / 1000)), + }); + }); + return router; +} + +export function createAgentRouter(ctx: WebContext): Router { + const router = Router(); + const jobs = new AgentJobManager((request) => executeJob(ctx, request)); + + router.get('/openapi.json', (_req, res) => res.json(createOpenApiDocument())); + router.get('/v1/capabilities', (_req, res) => res.json(capabilities())); + router.get('/v1/readyz', (_req, res) => { + const readiness = buildReadiness(ctx); + res.status(readiness.ready ? 200 : 503).json(readiness); + }); + router.get('/v1/overview', (_req, res) => res.json(buildOverview(ctx))); + + router.get('/v1/control', (_req, res) => res.json(botControlStatus(ctx))); + router.post('/v1/control', (req, res) => { + const body = ControlRequestSchema.safeParse(req.body); + if (!body.success) { + res.status(400).json({ error: 'Invalid control request', detail: body.error.format() }); + return; + } + const result = setBotRunning(ctx, body.data.running, 'agent'); + res.status(result.success ? 200 : 409).json(result); + }); + router.post('/v1/control/start', (_req, res) => { + const result = setBotRunning(ctx, true, 'agent'); + res.status(result.success ? 200 : 409).json(result); + }); + router.post('/v1/control/stop', (_req, res) => res.json(setBotRunning(ctx, false, 'agent'))); + + router.get('/v1/jobs', (req, res) => { + const limit = Number.parseInt(String(req.query.limit ?? '20'), 10); + res.json(jobs.list(limit)); + }); + router.get('/v1/jobs/:id', (req, res) => { + const job = jobs.get(req.params.id); + if (!job) { + res.status(404).json({ error: 'Job not found' }); + return; + } + res.json(job); + }); + router.post('/v1/jobs', (req, res) => { + const body = AgentJobRequestSchema.safeParse(req.body); + if (!body.success) { + res.status(400).json({ error: 'Invalid job request', detail: body.error.format() }); + return; + } + res.status(202).json(jobs.create(body.data)); + }); + + return router; +} + +function runningRuntimes(ctx: WebContext) { + return (ctx.services.accountRuntimes?.list() ?? []) + .filter((snapshot) => snapshot.state === 'running') + .flatMap((snapshot) => { + const runtime = ctx.services.accountRuntimes?.get(snapshot.accountId); + return runtime ? [runtime] : []; + }); +} + +function buildReadiness(ctx: WebContext) { + const components = { + database: { ready: Boolean(ctx.repos.messages && ctx.repos.posts), detail: 'SQLite repositories initialized' }, + accounts: { + ready: Boolean(ctx.services.redditAccounts), + detail: ctx.services.redditAccounts ? 'Account service initialized' : 'Account service unavailable', + }, + rateLimiter: { + ready: Boolean(ctx.services.rateLimiter), + detail: ctx.services.rateLimiter ? 'Rate limiter initialized' : 'Rate limiter unavailable', + }, + chat: { + ready: Boolean(ctx.services.chat), + detail: ctx.services.chat ? 'Chat service initialized' : 'Chat service unavailable', + }, + authentication: { + ready: ctx.services.accountAuth?.isFullyAuthenticated() ?? false, + detail: ctx.services.accountAuth?.getSnapshot().reason ?? 'At least one account is authenticated', + }, + }; + const essentialReady = components.database.ready && components.accounts.ready + && components.rateLimiter.ready && components.chat.ready; + const ready = essentialReady; + return { + ready, + status: !essentialReady ? 'unavailable' as const : components.authentication.ready ? 'ready' as const : 'degraded' as const, + checkedAt: new Date().toISOString(), + components, + }; +} + +function buildOverview(ctx: WebContext) { + const runtimes = runningRuntimes(ctx); + const runtimeSnapshots = ctx.services.accountRuntimes?.list() ?? []; + const accounts = (ctx.services.redditAccounts?.list() ?? []).map((account) => { + const runtime = runtimeSnapshots.find((item) => item.accountId === account.accountId); + const live = runtime ? ctx.services.accountRuntimes?.get(runtime.accountId) : undefined; + return { + accountId: account.accountId, + redditUsername: account.redditUsername, + runtimeState: runtime?.state ?? 'stopped', + authenticated: live?.resources.accountAuth.isFullyAuthenticated() ?? false, + failure: runtime?.failure ?? null, + updatedAt: account.updatedAt, + }; + }); + + const chatStatuses = runtimes.length > 0 + ? runtimes.map((runtime) => runtime.resources.chatService.getSyncStatus()) + : ctx.services.chat ? [ctx.services.chat.getSyncStatus()] : []; + const activeChat = chatStatuses.find((status) => status.syncing); + const mediaStatuses = runtimes.length > 0 + ? runtimes.map((runtime) => runtime.resources.mediaSyncService.getStatus()) + : ctx.services.mediaSync ? [ctx.services.mediaSync.getStatus()] : []; + const cooldown = runtimes.map((runtime) => runtime.resources.chatService.getInvitationCooldown()).find(Boolean) + ?? ctx.services.chat?.getInvitationCooldown() + ?? null; + const messagesThisHour = runtimes.length > 0 + ? runtimes.reduce((total, runtime) => total + runtime.resources.rateLimiter.getStats().messagesThisHour, 0) + : ctx.getMessagesThisHour(); + const authenticated = accounts.some((account) => account.authenticated) + || (ctx.services.accountAuth?.isFullyAuthenticated() ?? false); + const recentErrors = ctx.broadcaster.getRecent(100) + .filter((entry) => entry.level === 'error') + .slice(-10) + .reverse() + .map(({ timestamp, message, data }) => ({ timestamp, message, ...(data === undefined ? {} : { data }) })); + + return { + generatedAt: new Date().toISOString(), + status: !ctx.getBotRunning() ? 'stopped' as const : authenticated ? 'operational' as const : 'degraded' as const, + bot: { + running: ctx.getBotRunning(), + dryRun: ctx.config.browser.dryRun, + authenticated, + messagesThisHour, + maxMessagesPerHour: ctx.config.rateLimit.maxMessagesPerHour * Math.max(1, runtimes.length), + invitationCooldown: cooldown ? { until: cooldown.until, reason: cooldown.reason } : null, + }, + accounts, + sync: { + chat: { + syncing: Boolean(activeChat), + kind: activeChat?.syncingKind ?? null, + lastFullSyncAt: newest(chatStatuses.map((status) => status.lastFullSyncAt)), + lastDeltaSyncAt: newest(chatStatuses.map((status) => status.lastDeltaSyncAt)), + cachedChats: chatStatuses.reduce((total, status) => total + status.cachedChats, 0), + }, + media: { + syncing: mediaStatuses.some((status) => status.isSyncing), + lastSyncAt: newest(mediaStatuses.map((status) => status.lastSyncAt)), + nextSyncAt: newest(mediaStatuses.map((status) => status.nextSyncAt)), + lastError: mediaStatuses.find((status) => status.lastError)?.lastError ?? null, + }, + }, + recentErrors, + links: { + capabilities: '/api/v1/capabilities', + openapi: '/api/openapi.json', + events: '/api/events/stream', + jobs: '/api/v1/jobs', + }, + }; +} + +async function executeJob(ctx: WebContext, request: { type: 'chat-sync' | 'media-sync'; kind: 'delta' | 'full'; accountId?: string }) { + const runtimes = runningRuntimes(ctx).filter((runtime) => !request.accountId || runtime.session.accountId === request.accountId); + if (request.accountId && runtimes.length === 0) throw new Error('Requested account is not running'); + + if (request.type === 'chat-sync') { + if (runtimes.length > 0) { + const results = await Promise.all(runtimes.map(async (runtime) => { + if (runtime.resources.chatService.getSyncStatus().syncing) throw new Error(`Chat sync already running for u/${runtime.session.redditUsername}`); + if (request.kind === 'full') { + const chats = await runtime.resources.chatService.fetchAllMessages(); + return { accountId: runtime.session.accountId, processed: chats.length }; + } + await runtime.resources.chatService.refreshTopChats(); + return { accountId: runtime.session.accountId, processed: runtime.resources.chatService.getSyncStatus().cachedChats }; + })); + return { accounts: results }; + } + if (!ctx.services.chat) throw new Error('Chat service not initialized'); + if (ctx.services.chat.getSyncStatus().syncing) throw new Error('Chat sync already running'); + if (request.kind === 'full') { + const chats = await ctx.services.chat.fetchAllMessages(); + return { processed: chats.length }; + } + await ctx.services.chat.refreshTopChats(); + return { processed: ctx.services.chat.getSyncStatus().cachedChats }; + } + + const services = runtimes.length > 0 + ? runtimes.map((runtime) => ({ accountId: runtime.session.accountId, service: runtime.resources.mediaSyncService })) + : ctx.services.mediaSync ? [{ accountId: 'legacy', service: ctx.services.mediaSync }] : []; + if (services.length === 0) throw new Error('Media sync service not initialized'); + const results = await Promise.all(services.map(async ({ accountId, service }) => ({ + accountId, + accepted: await service.triggerSync(request.kind === 'full'), + }))); + if (!results.some((result) => result.accepted)) throw new Error('Media sync already running'); + return { accounts: results }; +} + +function newest(values: Array): number | null { + const present = values.filter((value): value is number => value !== null); + return present.length > 0 ? Math.max(...present) : null; +} diff --git a/src/web/routes/analytics.ts b/src/web/routes/analytics.ts index d0623b5..a520f4a 100644 --- a/src/web/routes/analytics.ts +++ b/src/web/routes/analytics.ts @@ -3,6 +3,7 @@ import type { WebContext } from '../context.js'; export function createAnalyticsRouter(ctx: WebContext): Router { const router = Router(); + const matureBefore = (): number => Date.now() - ctx.config.strategies.responseWindowHours * 60 * 60 * 1000; router.get('/timers', (_req, res) => { if (!ctx.services.rateLimiter) { @@ -13,17 +14,24 @@ export function createAnalyticsRouter(ctx: WebContext): Router { }); router.get('/analytics/by-subreddit', (_req, res) => { - res.json(ctx.repos.messages.getSubredditStats()); + res.json(ctx.repos.messages.getSubredditStats(matureBefore())); }); router.get('/analytics/by-strategy', (_req, res) => { if (ctx.services.strategySelector) { res.json(ctx.services.strategySelector.getStats()); } else { - res.json(ctx.repos.messages.getStrategyStats()); + res.json(ctx.repos.messages.getStrategyStats(matureBefore())); } }); + router.get('/analytics/funnel', (_req, res) => { + res.json({ + responseWindowHours: ctx.config.strategies.responseWindowHours, + ...ctx.repos.messages.getFunnelStats(matureBefore()), + }); + }); + router.get('/analytics/trend', (req, res) => { const days = parseInt(req.query.days as string) || 30; res.json(ctx.repos.messages.getResponseRateTrend(days)); diff --git a/src/web/routes/control.ts b/src/web/routes/control.ts index da08e88..c1802b2 100644 --- a/src/web/routes/control.ts +++ b/src/web/routes/control.ts @@ -1,28 +1,20 @@ import { Router } from 'express'; import type { WebContext } from '../context.js'; +import { setBotRunning } from '../bot-control.js'; export function createControlRouter(ctx: WebContext): Router { const router = Router(); router.post('/start', (_req, res) => { - if (!ctx.services.accountAuth?.isFullyAuthenticated()) { - ctx.logger.warn('Bot start blocked until full account authentication'); - res.status(409).json({ success: false, running: false, authenticated: false }); - return; - } - ctx.setBotRunning(true); - ctx.activity.push('sent', 'Bot started by user'); - ctx.logger.info('Bot started via web UI'); - ctx.broadcaster.dashboardEvents.bot({ running: true, dryRun: ctx.config.browser.dryRun }); - res.json({ success: true, running: true }); + const result = setBotRunning(ctx, true); + res.status(result.success ? 200 : 409).json(result.success + ? { success: true, running: result.running } + : { success: false, running: false, authenticated: false }); }); router.post('/stop', (_req, res) => { - ctx.setBotRunning(false); - ctx.activity.push('skipped', 'Bot stopped by user'); - ctx.logger.info('Bot stopped via web UI'); - ctx.broadcaster.dashboardEvents.bot({ running: false, dryRun: ctx.config.browser.dryRun }); - res.json({ success: true, running: false }); + const result = setBotRunning(ctx, false); + res.json({ success: true, running: result.running }); }); return router; diff --git a/src/web/server.ts b/src/web/server.ts index 2fe3849..600ed1d 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -40,6 +40,7 @@ import { createConfigRouter } from './routes/config.js'; import { createLoginRouter } from './routes/login.js'; import { createDashboardAuth, createDashboardSessionRouter } from './auth.js'; import { resolveDashboardAssets } from './dashboard-assets.js'; +import { createAgentRouter, createHealthRouter } from './routes/agent.js'; export type { BotStats, ActivityItem, LogEntry, RuntimeConfig } from './types.js'; @@ -120,8 +121,12 @@ export class WebServer { throw new Error('Web dashboard requires web.authToken or WEB_DASHBOARD_TOKEN'); } this.app.use(express.json({ limit: '64kb' })); + this.app.use(createHealthRouter()); this.app.use(createDashboardSessionRouter(dashboardToken)); - this.app.use(createDashboardAuth(dashboardToken)); + this.app.use(createDashboardAuth(dashboardToken, { + readToken: this.config.web.agentReadToken, + controlToken: this.config.web.agentControlToken, + })); const dashboardAssets = resolveDashboardAssets(this.workspaceDirectory); @@ -140,6 +145,7 @@ export class WebServer { this.app.use(express.static(dashboardAssets.directory, { index: false })); const ctx = this.context(); + this.app.use('/api', createAgentRouter(ctx)); this.app.use('/api', createStatsRouter(ctx)); this.app.use('/api', createChatsRouter(ctx)); this.app.use('/api', createConversationsRouter(ctx)); diff --git a/tests/agent-job-manager.test.ts b/tests/agent-job-manager.test.ts new file mode 100644 index 0000000..d734d7f --- /dev/null +++ b/tests/agent-job-manager.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AgentJobManager } from '../src/web/agent-job-manager.js'; + +describe('AgentJobManager', () => { + it('tracks an asynchronous job through completion', async () => { + const execute = vi.fn(async () => ({ processed: 4 })); + const manager = new AgentJobManager(execute); + const created = manager.create({ type: 'chat-sync', kind: 'delta' }); + + expect(created.status).toBe('queued'); + await vi.waitFor(() => expect(manager.get(created.id)?.status).toBe('succeeded')); + expect(manager.get(created.id)).toMatchObject({ result: { processed: 4 }, error: null }); + }); + + it('records executor failures without losing the job', async () => { + const manager = new AgentJobManager(async () => { throw new Error('sync unavailable'); }); + const created = manager.create({ type: 'media-sync', kind: 'full' }); + + await vi.waitFor(() => expect(manager.get(created.id)?.status).toBe('failed')); + expect(manager.get(created.id)?.error).toBe('sync unavailable'); + }); +}); diff --git a/tests/api-catalog.test.ts b/tests/api-catalog.test.ts new file mode 100644 index 0000000..f86c84a --- /dev/null +++ b/tests/api-catalog.test.ts @@ -0,0 +1,52 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { API_OPERATIONS, capabilities, createOpenApiDocument } from '../src/web/api-catalog.js'; + +describe('agent API discovery', () => { + it('publishes every operation under a unique method and path', () => { + const keys = API_OPERATIONS.map((operation) => `${operation.method} ${operation.path}`); + expect(new Set(keys).size).toBe(keys.length); + expect(keys).toContain('GET /api/v1/overview'); + expect(keys).toContain('POST /api/v1/control/start'); + expect(keys).toContain('POST /api/v1/control/stop'); + expect(keys).toContain('POST /api/v1/jobs'); + expect(keys).toContain('GET /api/events/stream'); + }); + + it('cannot drift from the Express route modules', () => { + const routesDirectory = path.resolve(process.cwd(), 'src/web/routes'); + const implemented = fs.readdirSync(routesDirectory) + .filter((file) => file.endsWith('.ts')) + .flatMap((file) => { + const source = fs.readFileSync(path.join(routesDirectory, file), 'utf8'); + return [...source.matchAll(/router\.(get|post|put|delete|patch)\('([^']+)'/g)].map((match) => { + const routePath = match[2] === '/healthz' ? match[2] : `/api${match[2]}`; + return `${match[1].toUpperCase()} ${routePath.replace(/:([A-Za-z0-9_]+)/g, '{$1}')}`; + }); + }); + const catalogued = API_OPERATIONS.map((item) => `${item.method} ${item.path}`); + + expect([...new Set(catalogued)].sort()).toEqual([...new Set(implemented)].sort()); + }); + + it('generates OpenAPI with safety metadata and schemas for versioned operations', () => { + const document = createOpenApiDocument() as { openapi: string; paths: Record>> }; + expect(document.openapi).toBe('3.1.0'); + expect(document.paths['/healthz'].get.security).toEqual([]); + expect(document.paths['/api/v1/overview'].get['x-access']).toBe('read'); + expect(document.paths['/api/v1/control/start'].post['x-access']).toBe('control'); + expect(document.paths['/api/v1/control/start'].post['x-mutates']).toBe(true); + expect(document.paths['/api/v1/jobs'].post.requestBody).toBeDefined(); + expect((document.paths['/api/v1/jobs'].post.responses as Record)['202']).toBeDefined(); + }); + + it('returns a compact capability catalogue for agents', () => { + const result = capabilities(); + expect(result.authentication.agentScopes).toEqual(['read', 'control', 'admin']); + expect(result.operations.find((item) => item.operationId === 'sendReply')).toMatchObject({ + access: 'admin', + mutates: true, + }); + }); +}); diff --git a/tests/chat-service-progressive-backfill.test.ts b/tests/chat-service-progressive-backfill.test.ts index 9984243..0072fbb 100644 --- a/tests/chat-service-progressive-backfill.test.ts +++ b/tests/chat-service-progressive-backfill.test.ts @@ -85,13 +85,9 @@ describe('ChatService progressive full backfill', () => { expect(repos.messagesRepo.getResponseRateTrend()).toEqual([ expect.objectContaining({ totalSent: 1, totalResponses: 1, responseRate: 100 }), ]); - expect(repos.successfulMessagesRepo.getRecent()).toEqual([ - expect.objectContaining({ - originalMessage: 'Hello Alice', - postTitle: 'Source post', - subreddit: 'example', - }), - ]); + // A single reply counts toward response performance, but the + // reciprocal-engagement policy excludes it from learning examples. + expect(repos.successfulMessagesRepo.getRecent()).toEqual([]); } finally { db.close(); await fs.rm(tmpDir, { recursive: true, force: true }); diff --git a/tests/config-persistence.test.ts b/tests/config-persistence.test.ts index f58a8df..8eb80f9 100644 --- a/tests/config-persistence.test.ts +++ b/tests/config-persistence.test.ts @@ -42,11 +42,15 @@ describe('ConfigStore', () => { const config = getTestConfig(); config.openrouter.apiKey = 'database-must-not-contain-this'; config.web.authToken = 'also-secret'; + config.web.agentReadToken = 'read-agent-secret'; + config.web.agentControlToken = 'control-agent-secret'; store.save(config); const row = db.prepare('SELECT config_json FROM app_config WHERE id = 1').get() as { config_json: string }; expect(row.config_json).not.toContain('database-must-not-contain-this'); expect(row.config_json).not.toContain('also-secret'); + expect(row.config_json).not.toContain('read-agent-secret'); + expect(row.config_json).not.toContain('control-agent-secret'); expect(JSON.parse(row.config_json)).not.toHaveProperty('database'); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index b33e8ea..2e34d5d 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -221,7 +221,8 @@ describe('ConfigSchema', () => { expect(result.success).toBe(true); if (result.success) { expect(result.data.strategies.explorationRate).toBe(0.2); - expect(result.data.strategies.minSamples).toBe(5); + expect(result.data.strategies.minSamples).toBe(30); + expect(result.data.strategies.responseWindowHours).toBe(48); expect(result.data.strategies.variants).toEqual([]); } }); diff --git a/tests/conversation-outcome-recorder.test.ts b/tests/conversation-outcome-recorder.test.ts new file mode 100644 index 0000000..5f82f80 --- /dev/null +++ b/tests/conversation-outcome-recorder.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { + ConversationOutcomesRepository, + MessagesRepository, + SentMessageRecord, + SuccessfulMessagesRepository, +} from '../src/database/repositories.js'; +import { recordConversationReply } from '../src/services/conversation-outcome-recorder.js'; +import { createMockLogger } from './mockLogger.js'; + +function message(): SentMessageRecord { + return { + id: 1, + postId: 'post-1', + recipient: 'alice', + subject: 'alice', + message: 'Hello', + sentAt: 1, + gotResponse: false, + gotImage: false, + responseCheckedAt: null, + strategyId: 'direct', + chatRoomId: 'room-1', + subreddit: 'example', + }; +} + +function dependencies() { + return { + messages: { markGotResponse: vi.fn() } as unknown as MessagesRepository, + successful: { insert: vi.fn(), existsByMessage: vi.fn(() => false) } as unknown as SuccessfulMessagesRepository, + outcomes: { record: vi.fn(), suppressRecipient: vi.fn() } as unknown as ConversationOutcomesRepository, + logger: createMockLogger(), + }; +} + +describe('recordConversationReply', () => { + it('records a first reply but excludes it from learning until engagement is reciprocal', () => { + const deps = dependencies(); + expect(recordConversationReply(message(), 'Thanks!', { messageCount: 2, reciprocal: false }, deps)).toBe('unknown'); + expect(deps.messages.markGotResponse).toHaveBeenCalledWith(1); + expect(deps.successful.insert).not.toHaveBeenCalled(); + }); + + it('stores only reciprocal engaged conversations as learning examples', () => { + const deps = dependencies(); + expect(recordConversationReply(message(), 'Tell me more', { messageCount: 4, reciprocal: true }, deps)).toBe('engaged'); + expect(deps.successful.insert).toHaveBeenCalledWith(expect.objectContaining({ sentMessageId: 1 })); + }); + + it('permanently suppresses a recipient after a boundary response', () => { + const deps = dependencies(); + expect(recordConversationReply(message(), 'Stop messaging me', { messageCount: 2 }, deps)).toBe('boundary'); + expect(deps.outcomes.suppressRecipient).toHaveBeenCalledWith('alice', 'boundary'); + expect(deps.successful.insert).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/conversation-outcome.test.ts b/tests/conversation-outcome.test.ts index a670803..b5a4131 100644 --- a/tests/conversation-outcome.test.ts +++ b/tests/conversation-outcome.test.ts @@ -3,11 +3,18 @@ import { classifyConversationOutcome } from '../src/services/conversation-outcom describe('conversation outcome classification', () => { it.each([ - ['That sounds fun, tell me more', 'engaged'], + ['That sounds fun, tell me more', 'unknown'], ['No thanks, not interested', 'declined'], ['Check my OnlyFans link in bio', 'upsell'], ['Stop messaging me', 'boundary'], ] as const)('classifies %s as %s', (reply, expected) => { expect(classifyConversationOutcome(reply)).toBe(expected); }); + + it('classifies a reciprocal multi-message conversation as engaged', () => { + expect(classifyConversationOutcome('That sounds fun, tell me more', { + messageCount: 4, + reciprocal: true, + })).toBe('engaged'); + }); }); diff --git a/tests/dashboard/performance-operations.test.tsx b/tests/dashboard/performance-operations.test.tsx index dea8768..ca5c5f7 100644 --- a/tests/dashboard/performance-operations.test.tsx +++ b/tests/dashboard/performance-operations.test.tsx @@ -68,11 +68,26 @@ describe('PerformancePage', () => { }, ]), ), + http.get('/api/analytics/funnel', () => HttpResponse.json({ + responseWindowHours: 48, + processed: 100, + sent: 40, + pending: 5, + mature: 35, + responded: 12, + engaged: 4, + declined: 2, + boundary: 1, + upsell: 3, + images: 2, + })), ); renderPage(); expect(await screen.findByRole('heading', { name: 'Performance' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Outcome funnel' })).toBeInTheDocument(); + expect(screen.getByText('5 awaiting maturity')).toBeInTheDocument(); const subredditTable = screen.getByRole('table', { name: 'Subreddit performance' }); expect(within(subredditTable).getByText('n = 40')).toBeInTheDocument(); expect(within(subredditTable).getByText('n = 3')).toBeInTheDocument(); diff --git a/tests/database.test.ts b/tests/database.test.ts index c6c9fa2..1a86b87 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -107,7 +107,7 @@ describe('Database Client', () => { .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") .all() as { name: string }[]; // Existing domain/config tables plus account, conversation, sync and media ownership stores. - expect(tables.length).toBe(13); + expect(tables.length).toBe(14); }); it('tracks schema version and migrates a legacy sent_messages table transactionally', () => { diff --git a/tests/mcp-api-client.test.ts b/tests/mcp-api-client.test.ts new file mode 100644 index 0000000..c27a581 --- /dev/null +++ b/tests/mcp-api-client.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest'; +import { RedditBotApiClient, RedditBotApiError } from '../src/mcp/api-client.js'; + +describe('RedditBotApiClient', () => { + it('uses a bearer token and parses JSON responses', async () => { + const fetchImplementation = vi.fn(async () => new Response(JSON.stringify({ running: true }), { status: 200 })); + const client = new RedditBotApiClient({ + baseUrl: 'http://redditbot.test/', + token: 'read-token', + fetchImplementation: fetchImplementation as typeof fetch, + }); + + await expect(client.get('/api/v1/control')).resolves.toEqual({ running: true }); + expect(fetchImplementation).toHaveBeenCalledWith('http://redditbot.test/api/v1/control', expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer read-token' }), + })); + }); + + it('returns structured API errors to MCP tools', async () => { + const client = new RedditBotApiClient({ + token: 'read-token', + fetchImplementation: vi.fn(async () => new Response(JSON.stringify({ error: 'Agent credential lacks the required access' }), { status: 403 })) as typeof fetch, + }); + + await expect(client.post('/api/v1/control/start')).rejects.toEqual( + new RedditBotApiError('Agent credential lacks the required access', 403), + ); + }); +}); diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts new file mode 100644 index 0000000..4aa9d8d --- /dev/null +++ b/tests/mcp-server.test.ts @@ -0,0 +1,47 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { RedditBotApiClient } from '../src/mcp/api-client.js'; +import { createRedditBotMcpServer } from '../src/mcp/server.js'; + +describe('RedditBot MCP server', () => { + const closeables: Array<{ close(): Promise }> = []; + + afterEach(async () => { + await Promise.all(closeables.splice(0).map((item) => item.close())); + }); + + async function connectedClient() { + const get = vi.fn(async () => ({ status: 'operational' })); + const post = vi.fn(async () => ({ success: true, running: true })); + const server = createRedditBotMcpServer({ get, post } as unknown as RedditBotApiClient); + const client = new Client({ name: 'test-client', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + closeables.push(client, server); + return { client, get, post }; + } + + it('advertises read, control, and explicit external-send tools', async () => { + const { client } = await connectedClient(); + const tools = await client.listTools(); + const names = tools.tools.map((tool) => tool.name); + + expect(names).toEqual(expect.arrayContaining([ + 'get_overview', 'list_accounts', 'list_conversations', 'start_bot', 'stop_bot', + 'set_bot_running', 'trigger_sync', 'send_reply', 'start_conversation', + ])); + expect(tools.tools.find((tool) => tool.name === 'get_overview')?.annotations?.readOnlyHint).toBe(true); + expect(tools.tools.find((tool) => tool.name === 'send_reply')?.annotations?.destructiveHint).toBe(true); + }); + + it('routes start and stop tools to the versioned control API', async () => { + const { client, post } = await connectedClient(); + + await client.callTool({ name: 'start_bot', arguments: {} }); + await client.callTool({ name: 'stop_bot', arguments: {} }); + + expect(post).toHaveBeenNthCalledWith(1, '/api/v1/control/start'); + expect(post).toHaveBeenNthCalledWith(2, '/api/v1/control/stop'); + }); +}); diff --git a/tests/monitor.test.ts b/tests/monitor.test.ts index 38d9024..8969f15 100644 --- a/tests/monitor.test.ts +++ b/tests/monitor.test.ts @@ -17,6 +17,7 @@ function createMockRedditApi(posts: RedditPost[] = []): RedditApiClient { function createMockPostsRepo(processedIds: Set = new Set()): PostsRepository { return { isProcessed: vi.fn((id: string) => processedIds.has(id)), + isProcessedAnywhere: vi.fn((id: string) => processedIds.has(id)), insert: vi.fn(), updateAnalysis: vi.fn(), markMessageSent: vi.fn(), @@ -86,6 +87,23 @@ describe('MonitorService', () => { expect(result.length).toBe(0); }); + it('uses shared post history so another account cannot rediscover a post', async () => { + const post: RedditPost = { + id: 't3_shared', subreddit: 'sub1', author: 'user1', title: 'Post', + url: 'http://example.com/1.jpg', permalink: '/r/sub1/1', thumbnail: '', + is_self: false, created_utc: Date.now() / 1000, over_18: false, spoiler: false, + }; + const redditApi = createMockRedditApi([post]); + const postsRepo = createMockPostsRepo(); + vi.mocked(postsRepo.isProcessed).mockReturnValue(false); + vi.mocked(postsRepo.isProcessedAnywhere).mockReturnValue(true); + const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }] } }); + monitor = new MonitorService(redditApi, postsRepo, createMockMessagesRepo(), config, logger, 0); + + await expect(monitor.getUnprocessedPosts()).resolves.toEqual([]); + expect(postsRepo.isProcessedAnywhere).toHaveBeenCalledWith(post.id); + }); + it('skips posts from users already messaged', async () => { const post1: RedditPost = { id: 't3_1', subreddit: 'sub1', author: 'already_msgd', title: 'Post 1', @@ -370,7 +388,7 @@ describe('MonitorService', () => { // (We can't directly test the shuffle, but we can verify it doesn't crash) }); - it('limits results to 10 per author via postsRepo', async () => { + it('checks shared processed-post history before considering a candidate', async () => { const post: RedditPost = { id: 't3_1', subreddit: 'sub1', author: 'user1', title: 'Post', url: 'http://example.com/1.jpg', permalink: '/r/sub1/1', thumbnail: '', @@ -385,8 +403,7 @@ describe('MonitorService', () => { monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0); await monitor.getUnprocessedPosts(); - // Verify isProcessed was called - expect(postsRepo.isProcessed).toHaveBeenCalledWith('t3_1'); + expect(postsRepo.isProcessedAnywhere).toHaveBeenCalledWith('t3_1'); }); describe('image/chat interleaving', () => { diff --git a/tests/reddit-api.test.ts b/tests/reddit-api.test.ts index 7805841..f3db3ed 100644 --- a/tests/reddit-api.test.ts +++ b/tests/reddit-api.test.ts @@ -3,6 +3,7 @@ import axios from 'axios'; import type { AxiosInstance } from 'axios'; import type { BrowserContext } from 'playwright'; import { RedditApiClient, RedditRateLimitError } from '../src/reddit/api.js'; +import type { RedditRequestScheduler } from '../src/reddit/request-coordinator.js'; import type { RedditPost } from '../src/reddit/api.js'; import { createMockLogger } from '../tests/mockLogger.js'; import { getTestConfig } from '../tests/testUtils.js'; @@ -517,6 +518,23 @@ describe('RedditApiClient', () => { )).toBe(true); }); + it('extends the shared request cooldown when Reddit returns 429', async () => { + const error = { + isAxiosError: true, + response: { status: 429, headers: { 'retry-after': '12' } }, + }; + const mockAxios = { get: vi.fn().mockRejectedValue(error) }; + vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance); + const scheduler: RedditRequestScheduler = { + schedule: async (request) => await request(), + extendCooldown: vi.fn(), + }; + const client2 = new RedditApiClient(getTestConfig(), logger, scheduler); + + await expect(client2.getNewPosts('test')).rejects.toMatchObject({ retryAfterMs: 12_000 }); + expect(scheduler.extendCooldown).toHaveBeenCalledWith(12_000); + }); + it('parses valid image posts from response', async () => { const mockResponse = { data: { data: { children: [ diff --git a/tests/reddit-request-coordinator.test.ts b/tests/reddit-request-coordinator.test.ts new file mode 100644 index 0000000..bfdf70a --- /dev/null +++ b/tests/reddit-request-coordinator.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { RedditRequestCoordinator } from '../src/reddit/request-coordinator.js'; + +describe('RedditRequestCoordinator', () => { + it('paces requests and applies a shared cooldown to the next queued request', async () => { + let now = 0; + const sleeps: number[] = []; + const coordinator = new RedditRequestCoordinator(6_500, { + now: () => now, + sleep: async (ms) => { sleeps.push(ms); now += ms; }, + }); + + await coordinator.schedule(async () => undefined); + coordinator.extendCooldown(30_000); + await coordinator.schedule(async () => undefined); + + expect(sleeps).toEqual([31_000]); + }); + + it('serializes concurrent requests through one process-wide queue', async () => { + const order: string[] = []; + let releaseFirst!: () => void; + const firstFinished = new Promise((resolve) => { releaseFirst = resolve; }); + const coordinator = new RedditRequestCoordinator(0); + + const first = coordinator.schedule(async () => { + order.push('first:start'); + await firstFinished; + order.push('first:end'); + }); + const second = coordinator.schedule(async () => { order.push('second'); }); + + await Promise.resolve(); + expect(order).toEqual(['first:start']); + releaseFirst(); + await Promise.all([first, second]); + expect(order).toEqual(['first:start', 'first:end', 'second']); + }); + + it('does not release a queued request before a failing request records its cooldown', async () => { + let now = 0; + const sleeps: number[] = []; + const coordinator = new RedditRequestCoordinator(0, { + now: () => now, + sleep: async (ms) => { sleeps.push(ms); now += ms; }, + }); + + const first = coordinator.schedule(async () => { + coordinator.extendCooldown(15_000); + throw new Error('429'); + }); + const second = coordinator.schedule(async () => undefined); + + await expect(first).rejects.toThrow('429'); + await second; + expect(sleeps).toEqual([16_000]); + }); +}); diff --git a/tests/repositories.test.ts b/tests/repositories.test.ts index 7cd0f04..abe0723 100644 --- a/tests/repositories.test.ts +++ b/tests/repositories.test.ts @@ -6,6 +6,8 @@ import { MessagesRepository, SuccessfulMessagesRepository, ConversationStateRepository, + ConversationOutcomesRepository, + RecipientHistoryRepository, } from '../src/database/repositories.js'; import { buildConversationState } from '../src/services/conversation-state.js'; @@ -607,6 +609,37 @@ describe('ConversationStateRepository', () => { }); }); +describe('Mature performance and guardrails', () => { + it('excludes pending sends and reports a confidence interval', () => { + const { postsRepo, messagesRepo } = createTestRepositories(); + postsRepo.insert({ id: 'old', subreddit: 'test', author: 'old-user', title: 'old', imageUrl: null, processedAt: 1 }); + postsRepo.insert({ id: 'new', subreddit: 'test', author: 'new-user', title: 'new', imageUrl: null, processedAt: 1 }); + const oldId = messagesRepo.insert({ + postId: 'old', recipient: 'old-user', subject: 's', message: 'm', + sentAt: Date.now() - 72 * 3600000, strategyId: 'direct', + }); + messagesRepo.markGotResponse(oldId); + messagesRepo.insert({ + postId: 'new', recipient: 'new-user', subject: 's', message: 'm', + sentAt: Date.now(), strategyId: 'direct', + }); + + expect(messagesRepo.getStrategyStats(Date.now() - 48 * 3600000)).toEqual([ + expect.objectContaining({ + strategyId: 'direct', totalSent: 1, totalResponses: 1, responseRate: 100, + pendingSent: 1, responseRateLow: expect.any(Number), responseRateHigh: expect.any(Number), + }), + ]); + }); + + it('suppresses boundary recipients across account history checks', () => { + const db = createTestDb(); + const outcomes = new ConversationOutcomesRepository(db); + outcomes.suppressRecipient('Alice', 'boundary'); + expect(new RecipientHistoryRepository(db).hasSentTo('alice')).toBe(true); + }); +}); + describe('Repository edge cases', () => { it('handles special characters in post titles', () => { const repos = createTestRepositories(); diff --git a/tests/response-checker.test.ts b/tests/response-checker.test.ts index 977c286..ca1fc29 100644 --- a/tests/response-checker.test.ts +++ b/tests/response-checker.test.ts @@ -194,7 +194,7 @@ describe('ResponseCheckerService', () => { expect(successfulMessagesRepo.insert).not.toHaveBeenCalled(); }); - it('stores successful messages for learning', async () => { + it('does not treat a single inbox reply as an engaged learning example', async () => { const post: ProcessedPostRecord = { id: 'post1', subreddit: 'gonewild', @@ -245,13 +245,8 @@ describe('ResponseCheckerService', () => { }); await checker.checkForResponses(); - expect(successfulMessagesRepo.insert).toHaveBeenCalled(); - - const insertCall = vi.mocked(successfulMessagesRepo.insert).mock.calls[0][0]; - expect(insertCall.originalMessage).toBe('Hey gorgeous!'); - expect(insertCall.imageAnalysis).toBe('Beautiful woman'); - expect(insertCall.postTitle).toBe('My selfie'); - expect(insertCall.subreddit).toBe('gonewild'); + expect(messagesRepo.markGotResponse).toHaveBeenCalledWith(1); + expect(successfulMessagesRepo.insert).not.toHaveBeenCalled(); }); it('does not turn a declined reply into a learning example', async () => { diff --git a/tests/testUtils.ts b/tests/testUtils.ts index 6aae868..ea697bb 100644 --- a/tests/testUtils.ts +++ b/tests/testUtils.ts @@ -14,6 +14,7 @@ export function createTestDb(): Database.Database { // Disable FK for tests - we manage referential integrity manually db.pragma('foreign_keys = OFF'); runMigrations(db); + db.pragma('foreign_keys = OFF'); return db; } @@ -77,6 +78,7 @@ export function getTestConfig(overrides?: Partial): Config { strategies: { explorationRate: 0.2, minSamples: 5, + responseWindowHours: 48, variants: [ { id: 'variant-a', weight: 1, promptOverride: 'Be direct' }, { id: 'variant-b', weight: 1, promptOverride: 'Be teasing' }, @@ -86,6 +88,7 @@ export function getTestConfig(overrides?: Partial): Config { chatStrategies: { explorationRate: 0.2, minSamples: 5, + responseWindowHours: 48, variants: [], ...overrides?.chatStrategies, }, diff --git a/tests/web-auth-cutover.test.ts b/tests/web-auth-cutover.test.ts index 9c59df0..7d9bc0a 100644 --- a/tests/web-auth-cutover.test.ts +++ b/tests/web-auth-cutover.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import type { BrowserClient } from '../src/browser/client.js'; import { ActivityLogRepository } from '../src/database/repositories.js'; import { WebServer } from '../src/web/server.js'; +import { OverviewResponseSchema } from '../src/web/agent-api-contracts.js'; import { createMockLogger } from './mockLogger.js'; import { createTestDb, createTestRepositories, getTestConfig } from './testUtils.js'; @@ -31,13 +32,25 @@ function createWorkspace(includeReactBuild = true): string { return workspace; } -async function startServer(options: { debugEnabled?: boolean; includeReactBuild?: boolean } = {}): Promise { +async function startServer(options: { + debugEnabled?: boolean; + includeReactBuild?: boolean; + agentReadToken?: string; + agentControlToken?: string; +} = {}): Promise { const database = createTestDb(); const repositories = createTestRepositories(database); const workspace = createWorkspace(options.includeReactBuild); const server = new WebServer( 0, - getTestConfig({ web: { enabled: true, port: 0, authToken: AUTH_TOKEN, debugEnabled: options.debugEnabled ?? false } }), + getTestConfig({ web: { + enabled: true, + port: 0, + authToken: AUTH_TOKEN, + debugEnabled: options.debugEnabled ?? false, + agentReadToken: options.agentReadToken, + agentControlToken: options.agentControlToken, + } }), repositories.postsRepo, repositories.messagesRepo, repositories.successfulMessagesRepo, @@ -110,4 +123,44 @@ describe('authenticated React dashboard cutover', () => { expect(upload.status).toBe(404); }); + it('exposes public liveness and scoped discovery/control APIs', async () => { + const running = await startServer({ + agentReadToken: 'agent-read-token', + agentControlToken: 'agent-control-token', + }); + runningServers.push(running); + running.server.setAccountAuth({ + isFullyAuthenticated: () => true, + } as never); + + const health = await fetch(`${running.baseUrl}/healthz`); + const openapi = await fetch(`${running.baseUrl}/api/openapi.json`, { + headers: { Authorization: 'Bearer agent-read-token' }, + }); + const overview = await fetch(`${running.baseUrl}/api/v1/overview`, { + headers: { Authorization: 'Bearer agent-read-token' }, + }); + const forbiddenStart = await fetch(`${running.baseUrl}/api/v1/control/start`, { + method: 'POST', + headers: { Authorization: 'Bearer agent-read-token' }, + }); + const allowedStart = await fetch(`${running.baseUrl}/api/v1/control/start`, { + method: 'POST', + headers: { Authorization: 'Bearer agent-control-token' }, + }); + const allowedStop = await fetch(`${running.baseUrl}/api/v1/control/stop`, { + method: 'POST', + headers: { Authorization: 'Bearer agent-control-token' }, + }); + + expect(health.status).toBe(200); + expect(await health.json()).toMatchObject({ status: 'ok', service: 'reddit-bot' }); + expect(openapi.status).toBe(200); + expect(await openapi.json()).toMatchObject({ openapi: '3.1.0' }); + expect(OverviewResponseSchema.safeParse(await overview.json()).success).toBe(true); + expect(forbiddenStart.status).toBe(403); + expect(await allowedStart.json()).toMatchObject({ success: true, running: true, changed: true }); + expect(await allowedStop.json()).toMatchObject({ success: true, running: false, changed: true }); + }); + }); diff --git a/tests/web-auth.test.ts b/tests/web-auth.test.ts index 3508968..8b25f2d 100644 --- a/tests/web-auth.test.ts +++ b/tests/web-auth.test.ts @@ -48,6 +48,28 @@ describe('dashboard authentication', () => { expect(await response.json()).toEqual({ ok: true }); }); + it('limits agent bearer tokens to their declared access', async () => { + const app = express(); + app.use(express.json()); + app.use(createDashboardAuth('dashboard-secret', { + readToken: 'agent-read-secret', + controlToken: 'agent-control-secret', + })); + app.get('/api/v1/overview', (_req, res) => res.json({ ok: true })); + app.post('/api/v1/control/start', (_req, res) => res.json({ ok: true })); + app.post('/api/conversations/start', (_req, res) => res.json({ ok: true })); + + const read = await request(app, '/api/v1/overview', { headers: { Authorization: 'Bearer agent-read-secret' } }); + const readMutation = await request(app, '/api/v1/control/start', { method: 'POST', headers: { Authorization: 'Bearer agent-read-secret' } }); + const control = await request(app, '/api/v1/control/start', { method: 'POST', headers: { Authorization: 'Bearer agent-control-secret' } }); + const legacyMutation = await request(app, '/api/conversations/start', { method: 'POST', headers: { Authorization: 'Bearer agent-control-secret' } }); + + expect(read.status).toBe(200); + expect(readMutation.status).toBe(403); + expect(control.status).toBe(200); + expect(legacyMutation.status).toBe(403); + }); + it('issues an HttpOnly session only for a schema-valid token payload', async () => { const app = express(); app.use(express.json()); diff --git a/tests/web/control-realtime.test.ts b/tests/web/control-realtime.test.ts index 5eb82ba..08abc2b 100644 --- a/tests/web/control-realtime.test.ts +++ b/tests/web/control-realtime.test.ts @@ -11,6 +11,7 @@ describe('control route realtime events', () => { const context = { config: { browser: { dryRun: true } }, services: { accountAuth: { isFullyAuthenticated: () => true } }, + getBotRunning: () => running, setBotRunning: (next: boolean) => { running = next; }, activity: { push: vi.fn() }, logger: { info: vi.fn() },
NameResponsesResponse rateImagesImage rateSample
NameResponsesMature response rateImagesImage rateSample
{row.totalResponses}{row.responseRate}%{row.responseRate}%{row.responseRateLow !== undefined && row.responseRateHigh !== undefined ? 95% CI {row.responseRateLow}–{row.responseRateHigh}% : null} {row.totalImages} {row.imageRate}%{row.pendingSent ? {row.pendingSent} pending : null}