feat: add agent API and operational analytics
This commit is contained in:
1 parent
720ea0652a
commit
e8b730b592
65 files changed
+2650
-1214
No files matched your search
@@ -9,6 +9,15 @@ Configure only the application secrets required by the Compose file:
|
|||||||
- `OPENROUTER_API_KEY`
|
- `OPENROUTER_API_KEY`
|
||||||
- `WEB_DASHBOARD_TOKEN`
|
- `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.
|
Do not configure Reddit usernames, passwords, display servers, CDP endpoints, or remote-browser ports.
|
||||||
|
|
||||||
## Account activation
|
## Account activation
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
StrategyPerformanceResponseSchema,
|
StrategyPerformanceResponseSchema,
|
||||||
SubredditPerformanceResponseSchema,
|
SubredditPerformanceResponseSchema,
|
||||||
SuccessfulExamplesResponseSchema,
|
SuccessfulExamplesResponseSchema,
|
||||||
|
FunnelResponseSchema,
|
||||||
} from '../../../../../packages/api-contracts/src/index.js';
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import { createDashboardRequest } from '../../api/request.js';
|
import { createDashboardRequest } from '../../api/request.js';
|
||||||
@@ -11,6 +12,7 @@ type SubredditPerformanceResponse = z.infer<typeof SubredditPerformanceResponseS
|
|||||||
type StrategyPerformanceResponse = z.infer<typeof StrategyPerformanceResponseSchema>;
|
type StrategyPerformanceResponse = z.infer<typeof StrategyPerformanceResponseSchema>;
|
||||||
type DailyTrendResponse = z.infer<typeof DailyTrendResponseSchema>;
|
type DailyTrendResponse = z.infer<typeof DailyTrendResponseSchema>;
|
||||||
type SuccessfulExamplesResponse = z.infer<typeof SuccessfulExamplesResponseSchema>;
|
type SuccessfulExamplesResponse = z.infer<typeof SuccessfulExamplesResponseSchema>;
|
||||||
|
type FunnelResponse = z.infer<typeof FunnelResponseSchema>;
|
||||||
|
|
||||||
async function parseResponse<T>(
|
async function parseResponse<T>(
|
||||||
response: Response,
|
response: Response,
|
||||||
@@ -35,6 +37,7 @@ export interface PerformanceApi {
|
|||||||
getStrategyPerformance(): Promise<StrategyPerformanceResponse>;
|
getStrategyPerformance(): Promise<StrategyPerformanceResponse>;
|
||||||
getDailyTrend(days: number): Promise<DailyTrendResponse>;
|
getDailyTrend(days: number): Promise<DailyTrendResponse>;
|
||||||
getSuccessfulExamples(): Promise<SuccessfulExamplesResponse>;
|
getSuccessfulExamples(): Promise<SuccessfulExamplesResponse>;
|
||||||
|
getFunnel(): Promise<FunnelResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPerformanceApi(
|
export function createPerformanceApi(
|
||||||
@@ -66,6 +69,12 @@ export function createPerformanceApi(
|
|||||||
SuccessfulExamplesResponseSchema,
|
SuccessfulExamplesResponseSchema,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
async getFunnel() {
|
||||||
|
return parseResponse(
|
||||||
|
await request('/api/analytics/funnel'),
|
||||||
|
FunnelResponseSchema,
|
||||||
|
);
|
||||||
|
},
|
||||||
} satisfies PerformanceApi;
|
} satisfies PerformanceApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import type {
|
|||||||
StrategyPerformanceResponseSchema,
|
StrategyPerformanceResponseSchema,
|
||||||
SubredditPerformanceResponseSchema,
|
SubredditPerformanceResponseSchema,
|
||||||
SuccessfulExample,
|
SuccessfulExample,
|
||||||
|
FunnelResponseSchema,
|
||||||
} from '../../../../../packages/api-contracts/src/index.js';
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import {
|
import {
|
||||||
useDailyTrendQuery,
|
useDailyTrendQuery,
|
||||||
useStrategyPerformanceQuery,
|
useStrategyPerformanceQuery,
|
||||||
useSubredditPerformanceQuery,
|
useSubredditPerformanceQuery,
|
||||||
useSuccessfulExamplesQuery,
|
useSuccessfulExamplesQuery,
|
||||||
|
useFunnelQuery,
|
||||||
} from './performance-queries.js';
|
} from './performance-queries.js';
|
||||||
import './performance.css';
|
import './performance.css';
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ const LOW_CONFIDENCE_SAMPLE_SIZE = 20;
|
|||||||
type SubredditPerformance = z.infer<typeof SubredditPerformanceResponseSchema>[number];
|
type SubredditPerformance = z.infer<typeof SubredditPerformanceResponseSchema>[number];
|
||||||
type StrategyPerformance = z.infer<typeof StrategyPerformanceResponseSchema>[number];
|
type StrategyPerformance = z.infer<typeof StrategyPerformanceResponseSchema>[number];
|
||||||
type DailyTrend = z.infer<typeof DailyTrendResponseSchema>[number];
|
type DailyTrend = z.infer<typeof DailyTrendResponseSchema>[number];
|
||||||
|
type Funnel = z.infer<typeof FunnelResponseSchema>;
|
||||||
|
|
||||||
function SampleSize({ totalSent }: { totalSent: number }) {
|
function SampleSize({ totalSent }: { totalSent: number }) {
|
||||||
const isLowConfidence = totalSent < LOW_CONFIDENCE_SAMPLE_SIZE;
|
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 (
|
||||||
|
<section aria-labelledby="funnel-heading" className="panel feature-panel">
|
||||||
|
<div className="panel-heading"><div><span className="eyebrow">Qualified engagement</span><h2 id="funnel-heading">Outcome funnel</h2></div><span className="sample-size">{funnel.pending} awaiting maturity</span></div>
|
||||||
|
<ol className="funnel-list">
|
||||||
|
{stages.map(([label, value]) => <li key={label}><span>{label}</span><strong>{value}</strong></li>)}
|
||||||
|
</ol>
|
||||||
|
<div className="guardrail-list" aria-label="Guardrail outcomes">
|
||||||
|
<span>Declined <strong>{funnel.declined}</strong></span>
|
||||||
|
<span>Boundary <strong>{funnel.boundary}</strong></span>
|
||||||
|
<span>Upsell <strong>{funnel.upsell}</strong></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PerformanceTable({
|
function PerformanceTable({
|
||||||
label,
|
label,
|
||||||
rows,
|
rows,
|
||||||
@@ -40,7 +67,7 @@ function PerformanceTable({
|
|||||||
return (
|
return (
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table aria-label={label} className="performance-table">
|
<table aria-label={label} className="performance-table">
|
||||||
<thead><tr><th scope="col">Name</th><th scope="col">Responses</th><th scope="col">Response rate</th><th scope="col">Images</th><th scope="col">Image rate</th><th scope="col">Sample</th></tr></thead>
|
<thead><tr><th scope="col">Name</th><th scope="col">Responses</th><th scope="col">Mature response rate</th><th scope="col">Images</th><th scope="col">Image rate</th><th scope="col">Sample</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<tr key={rowLabel(row)}>
|
<tr key={rowLabel(row)}>
|
||||||
@@ -49,10 +76,10 @@ function PerformanceTable({
|
|||||||
{'configured' in row && row.configured ? <span className="configured-label">Configured</span> : null}
|
{'configured' in row && row.configured ? <span className="configured-label">Configured</span> : null}
|
||||||
</th>
|
</th>
|
||||||
<td>{row.totalResponses}</td>
|
<td>{row.totalResponses}</td>
|
||||||
<td>{row.responseRate}%</td>
|
<td>{row.responseRate}%{row.responseRateLow !== undefined && row.responseRateHigh !== undefined ? <small className="interval">95% CI {row.responseRateLow}–{row.responseRateHigh}%</small> : null}</td>
|
||||||
<td>{row.totalImages}</td>
|
<td>{row.totalImages}</td>
|
||||||
<td>{row.imageRate}%</td>
|
<td>{row.imageRate}%</td>
|
||||||
<td><SampleSize totalSent={row.totalSent} /></td>
|
<td><SampleSize totalSent={row.totalSent} />{row.pendingSent ? <small className="interval">{row.pendingSent} pending</small> : null}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -101,7 +128,8 @@ export function PerformancePage() {
|
|||||||
const strategies = useStrategyPerformanceQuery();
|
const strategies = useStrategyPerformanceQuery();
|
||||||
const trend = useDailyTrendQuery(30);
|
const trend = useDailyTrendQuery(30);
|
||||||
const examples = useSuccessfulExamplesQuery();
|
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)) {
|
if (queries.some((query) => query.isPending)) {
|
||||||
return <div className="page-state" role="status">Loading performance…</div>;
|
return <div className="page-state" role="status">Loading performance…</div>;
|
||||||
}
|
}
|
||||||
@@ -113,6 +141,7 @@ export function PerformancePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="page performance-page">
|
<div className="page performance-page">
|
||||||
<header className="page-header"><div><span className="eyebrow">Decision support</span><h1>Performance</h1><p>Compare response and received-image outcomes without hiding the sample sizes behind the percentages.</p></div></header>
|
<header className="page-header"><div><span className="eyebrow">Decision support</span><h1>Performance</h1><p>Compare response and received-image outcomes without hiding the sample sizes behind the percentages.</p></div></header>
|
||||||
|
{funnel.data ? <FunnelPanel funnel={funnel.data} /> : null}
|
||||||
<section aria-labelledby="trend-heading" className="panel feature-panel"><div className="panel-heading"><div><span className="eyebrow">Last 30 days</span><h2 id="trend-heading">Response trend</h2></div></div><Trend rows={trend.data ?? []} /></section>
|
<section aria-labelledby="trend-heading" className="panel feature-panel"><div className="panel-heading"><div><span className="eyebrow">Last 30 days</span><h2 id="trend-heading">Response trend</h2></div></div><Trend rows={trend.data ?? []} /></section>
|
||||||
<div className="performance-grid">
|
<div className="performance-grid">
|
||||||
<section aria-labelledby="subreddit-heading" className="panel feature-panel"><div className="panel-heading"><h2 id="subreddit-heading">By subreddit</h2></div><PerformanceTable label="Subreddit performance" rows={subreddits.data ?? []} rowLabel={(row) => 'subreddit' in row ? row.subreddit : row.strategyId} /></section>
|
<section aria-labelledby="subreddit-heading" className="panel feature-panel"><div className="panel-heading"><h2 id="subreddit-heading">By subreddit</h2></div><PerformanceTable label="Subreddit performance" rows={subreddits.data ?? []} rowLabel={(row) => 'subreddit' in row ? row.subreddit : row.strategyId} /></section>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const performanceQueryKeys = {
|
|||||||
strategies: ['performance', 'strategies'] as const,
|
strategies: ['performance', 'strategies'] as const,
|
||||||
trend: (days: number) => ['performance', 'trend', days] as const,
|
trend: (days: number) => ['performance', 'trend', days] as const,
|
||||||
successfulExamples: ['performance', 'successful-examples'] as const,
|
successfulExamples: ['performance', 'successful-examples'] as const,
|
||||||
|
funnel: ['performance', 'funnel'] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useSubredditPerformanceQuery() {
|
export function useSubredditPerformanceQuery() {
|
||||||
@@ -35,3 +36,10 @@ export function useSuccessfulExamplesQuery() {
|
|||||||
queryFn: performanceApi.getSuccessfulExamples,
|
queryFn: performanceApi.getSuccessfulExamples,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useFunnelQuery() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: performanceQueryKeys.funnel,
|
||||||
|
queryFn: performanceApi.getFunnel,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,6 +9,11 @@
|
|||||||
.configured-label, .confidence-warning { display: block; margin-top: .2rem; color: var(--accent); font-size: .68rem; font-weight: 750; }
|
.configured-label, .confidence-warning { display: block; margin-top: .2rem; color: var(--accent); font-size: .68rem; font-weight: 750; }
|
||||||
.confidence-warning { color: var(--warning); }
|
.confidence-warning { color: var(--warning); }
|
||||||
.sample-size { white-space: nowrap; color: var(--muted); }
|
.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 { 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-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; }
|
.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 blockquote { margin: 1rem 0; padding-left: 1rem; border-left: 2px solid var(--accent); }
|
||||||
.successful-example p { margin-bottom: 0; color: var(--muted); }
|
.successful-example p { margin-bottom: 0; color: var(--muted); }
|
||||||
.feature-empty { 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)); } }
|
||||||
@@ -15,6 +15,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
||||||
- WEB_DASHBOARD_TOKEN=${WEB_DASHBOARD_TOKEN}
|
- 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:
|
volumes:
|
||||||
reddit_bot_data:
|
reddit_bot_data:
|
||||||
+102
-1015
File diff suppressed because it is too large.
Load diff
+11
-1
@@ -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
|
3. Validates the entire configuration tree using Zod
|
||||||
4. Returns a strongly-typed `Config` object
|
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...` |
|
| `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` |
|
| `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` |
|
| `DATABASE_PATH` | No | string | SQLite database location | `./data/reddit-bot.db` |
|
||||||
|
|
||||||
### Setting Environment Variables
|
### Setting Environment Variables
|
||||||
@@ -98,6 +102,8 @@ Create a `.env` file in the project root:
|
|||||||
```env
|
```env
|
||||||
OPENROUTER_API_KEY=sk-or-v1-abc123...
|
OPENROUTER_API_KEY=sk-or-v1-abc123...
|
||||||
WEB_DASHBOARD_TOKEN=long-random-secret
|
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.
|
> **Note**: Add `.env` to `.gitignore` - never commit credentials.
|
||||||
@@ -107,6 +113,8 @@ WEB_DASHBOARD_TOKEN=long-random-secret
|
|||||||
```bash
|
```bash
|
||||||
export OPENROUTER_API_KEY=sk-or-v1-abc123...
|
export OPENROUTER_API_KEY=sk-or-v1-abc123...
|
||||||
export WEB_DASHBOARD_TOKEN=long-random-secret
|
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**
|
**Option 3: Docker**
|
||||||
@@ -117,6 +125,8 @@ Pass via `docker-compose.yml` or Docker CLI:
|
|||||||
environment:
|
environment:
|
||||||
- OPENROUTER_API_KEY=sk-or-v1-abc123...
|
- OPENROUTER_API_KEY=sk-or-v1-abc123...
|
||||||
- WEB_DASHBOARD_TOKEN=long-random-secret
|
- WEB_DASHBOARD_TOKEN=long-random-secret
|
||||||
|
- REDDIT_BOT_AGENT_READ_TOKEN=separate-read-secret
|
||||||
|
- REDDIT_BOT_AGENT_CONTROL_TOKEN=separate-control-secret
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+7
-1
@@ -91,6 +91,8 @@ Set environment variables:
|
|||||||
```bash
|
```bash
|
||||||
export OPENROUTER_API_KEY="sk-or-v1-your-key"
|
export OPENROUTER_API_KEY="sk-or-v1-your-key"
|
||||||
export WEB_DASHBOARD_TOKEN="long-random-secret"
|
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
|
### 3. Import account session
|
||||||
@@ -135,6 +137,8 @@ docker run -d \
|
|||||||
-v $(pwd)/data:/app/data \
|
-v $(pwd)/data:/app/data \
|
||||||
-e OPENROUTER_API_KEY=sk-or-v1-your-key \
|
-e OPENROUTER_API_KEY=sk-or-v1-your-key \
|
||||||
-e WEB_DASHBOARD_TOKEN=long-random-secret \
|
-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
|
reddit-bot:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -169,9 +173,11 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
||||||
- WEB_DASHBOARD_TOKEN=${WEB_DASHBOARD_TOKEN}
|
- 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
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--spider", "http://localhost:3000/api/stats"]
|
test: ["CMD", "wget", "--spider", "http://localhost:3000/healthz"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
Generated
+296
-1
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||||
"@tanstack/react-query": "^5.90.20",
|
"@tanstack/react-query": "^5.90.20",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"better-sqlite3": "^13.0.3",
|
"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": {
|
"node_modules/@inquirer/ansi": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz",
|
||||||
@@ -1197,6 +1210,46 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@mswjs/interceptors": {
|
||||||
"version": "0.41.9",
|
"version": "0.41.9",
|
||||||
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz",
|
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz",
|
||||||
@@ -2165,6 +2218,39 @@
|
|||||||
"node": ">= 14"
|
"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": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
@@ -2563,6 +2649,37 @@
|
|||||||
"node": ">=6.6.0"
|
"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": {
|
"node_modules/css-tree": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||||
@@ -2890,6 +3007,27 @@
|
|||||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
@@ -2943,6 +3081,25 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/express/node_modules/mime-db": {
|
||||||
"version": "1.54.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||||
@@ -2968,6 +3125,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/fast-string-truncated-width": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz",
|
"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"
|
"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": {
|
"node_modules/fast-wrap-ansi": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz",
|
"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"
|
"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": {
|
"node_modules/html-encoding-sniffer": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
"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==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -3421,6 +3618,21 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -3482,6 +3694,18 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/json5": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
@@ -3746,6 +3970,15 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -3892,6 +4125,15 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "8.4.2",
|
"version": "8.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
"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"
|
"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": {
|
"node_modules/playwright": {
|
||||||
"version": "1.57.0",
|
"version": "1.57.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
||||||
@@ -4214,7 +4465,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"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==",
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -4436,6 +4686,27 @@
|
|||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
"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": "^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": {
|
"node_modules/why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
@@ -5391,6 +5677,15 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
"dashboard:typecheck": "tsc -p apps/dashboard/tsconfig.json --noEmit",
|
"dashboard:typecheck": "tsc -p apps/dashboard/tsconfig.json --noEmit",
|
||||||
"start": "tsx src/index.ts",
|
"start": "tsx src/index.ts",
|
||||||
"dev": "tsx watch 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": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
@@ -24,6 +27,7 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||||
"@tanstack/react-query": "^5.90.20",
|
"@tanstack/react-query": "^5.90.20",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
|
|||||||
@@ -75,7 +75,11 @@ export const ConversationDetailResponseSchema = ActiveChatSchema.extend({
|
|||||||
staleMessages: z.boolean(),
|
staleMessages: z.boolean(),
|
||||||
}).nullable(),
|
}).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 SendReplyRequestSchema = z.strictObject({ message: z.string().min(1) });
|
||||||
export const StartConversationRequestSchema = z.strictObject({
|
export const StartConversationRequestSchema = z.strictObject({
|
||||||
accountId: z.string().min(1),
|
accountId: z.string().min(1),
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export const SentMessageSchema = z.strictObject({
|
|||||||
responseCheckedAt: z.number().nullable(),
|
responseCheckedAt: z.number().nullable(),
|
||||||
strategyId: z.string().nullable(),
|
strategyId: z.string().nullable(),
|
||||||
chatRoomId: 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(),
|
subreddit: z.string().optional(),
|
||||||
postTitle: z.string().optional(),
|
postTitle: z.string().optional(),
|
||||||
postUrl: z.string().optional(),
|
postUrl: z.string().optional(),
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ const PerformanceFields = {
|
|||||||
responseRate: z.number(),
|
responseRate: z.number(),
|
||||||
totalImages: z.number(),
|
totalImages: z.number(),
|
||||||
imageRate: z.number(),
|
imageRate: z.number(),
|
||||||
|
pendingSent: z.number().optional(),
|
||||||
|
responseRateLow: z.number().optional(),
|
||||||
|
responseRateHigh: z.number().optional(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StatsResponseSchema = z.strictObject({
|
export const StatsResponseSchema = z.strictObject({
|
||||||
@@ -57,5 +60,19 @@ export const DailyTrendSchema = z.strictObject({
|
|||||||
});
|
});
|
||||||
export const DailyTrendResponseSchema = z.array(DailyTrendSchema);
|
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<typeof StatsResponseSchema>;
|
export type StatsResponse = z.infer<typeof StatsResponseSchema>;
|
||||||
export type TimersResponse = z.infer<typeof TimersResponseSchema>;
|
export type TimersResponse = z.infer<typeof TimersResponseSchema>;
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
StrategyPerformanceResponseSchema,
|
StrategyPerformanceResponseSchema,
|
||||||
SubredditPerformanceResponseSchema,
|
SubredditPerformanceResponseSchema,
|
||||||
DailyTrendResponseSchema,
|
DailyTrendResponseSchema,
|
||||||
|
FunnelResponseSchema,
|
||||||
SuccessfulExamplesResponseSchema,
|
SuccessfulExamplesResponseSchema,
|
||||||
TimersResponseSchema,
|
TimersResponseSchema,
|
||||||
} from '../src/index.js';
|
} from '../src/index.js';
|
||||||
@@ -90,6 +91,11 @@ describe('dashboard endpoint contracts', () => {
|
|||||||
responseCheckedAt: null,
|
responseCheckedAt: null,
|
||||||
strategyId: null,
|
strategyId: null,
|
||||||
chatRoomId: null,
|
chatRoomId: null,
|
||||||
|
experimentId: 'message:variant-a:abc123',
|
||||||
|
promptVersion: 'abc123',
|
||||||
|
modelId: 'openai/gpt-5.2',
|
||||||
|
selectionReason: 'exploit',
|
||||||
|
sourceType: 'image',
|
||||||
}]).success).toBe(true);
|
}]).success).toBe(true);
|
||||||
expect(SuccessfulExamplesResponseSchema.safeParse([{
|
expect(SuccessfulExamplesResponseSchema.safeParse([{
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -124,6 +130,10 @@ describe('dashboard endpoint contracts', () => {
|
|||||||
expect(DailyTrendResponseSchema.safeParse([{
|
expect(DailyTrendResponseSchema.safeParse([{
|
||||||
date: '2026-08-08', totalSent: 10, totalResponses: 4, responseRate: 40, totalImages: 2, imageRate: 20,
|
date: '2026-08-08', totalSent: 10, totalResponses: 4, responseRate: 40, totalImages: 2, imageRate: 20,
|
||||||
}]).success).toBe(true);
|
}]).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);
|
expect(DailyTrendResponseSchema.safeParse([{ date: '2026-08-08', totalSent: '10' }]).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+3
-1
@@ -16,6 +16,7 @@ import {
|
|||||||
} from './database/repositories.js';
|
} from './database/repositories.js';
|
||||||
import { BrowserClient } from './browser/client.js';
|
import { BrowserClient } from './browser/client.js';
|
||||||
import { RedditApiClient } from './reddit/api.js';
|
import { RedditApiClient } from './reddit/api.js';
|
||||||
|
import { RedditRequestCoordinator } from './reddit/request-coordinator.js';
|
||||||
import { OpenRouterClient } from './ai/openrouter.js';
|
import { OpenRouterClient } from './ai/openrouter.js';
|
||||||
import { MonitorService } from './services/monitor.js';
|
import { MonitorService } from './services/monitor.js';
|
||||||
import { ProcessorService } from './services/processor.js';
|
import { ProcessorService } from './services/processor.js';
|
||||||
@@ -158,7 +159,7 @@ export async function bootstrap(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reddit API client (shares browser context for auth)
|
// 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()!);
|
redditApi.setBrowserContext(browserClient.getContext()!);
|
||||||
|
|
||||||
// Dedicated page for the response checker (background scraping that
|
// Dedicated page for the response checker (background scraping that
|
||||||
@@ -273,6 +274,7 @@ export async function bootstrap(
|
|||||||
messagesRepo,
|
messagesRepo,
|
||||||
redditUsersRepo,
|
redditUsersRepo,
|
||||||
accountAuth,
|
accountAuth,
|
||||||
|
conversationOutcomesRepo,
|
||||||
);
|
);
|
||||||
|
|
||||||
const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs, accountAuth);
|
const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs, accountAuth);
|
||||||
|
|||||||
+19
-5
@@ -108,7 +108,7 @@ export const ConfigSchema = z.object({
|
|||||||
imageAnalysisPrompt: z.string(),
|
imageAnalysisPrompt: z.string(),
|
||||||
messageGenerationPrompt: z.string(),
|
messageGenerationPrompt: z.string(),
|
||||||
chatOpenerPrompt: z.string().default(
|
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({
|
rateLimit: z.object({
|
||||||
@@ -129,27 +129,33 @@ export const ConfigSchema = z.object({
|
|||||||
}).default({ syncIntervalMs: 1800000, syncEnabled: true }),
|
}).default({ syncIntervalMs: 1800000, syncEnabled: true }),
|
||||||
strategies: z.object({
|
strategies: z.object({
|
||||||
explorationRate: z.number().min(0).max(1).default(0.2),
|
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({
|
variants: z.array(z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
weight: z.number().default(1),
|
weight: z.number().default(1),
|
||||||
promptOverride: z.string(),
|
promptOverride: z.string(),
|
||||||
})).default([]),
|
})).default([]),
|
||||||
}).default({ explorationRate: 0.2, minSamples: 5, variants: [] }),
|
}).default({ explorationRate: 0.2, minSamples: 30, responseWindowHours: 48, variants: [] }),
|
||||||
chatStrategies: z.object({
|
chatStrategies: z.object({
|
||||||
explorationRate: z.number().min(0).max(1).default(0.2),
|
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({
|
variants: z.array(z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
weight: z.number().default(1),
|
weight: z.number().default(1),
|
||||||
promptOverride: z.string(),
|
promptOverride: z.string(),
|
||||||
})).default([]),
|
})).default([]),
|
||||||
}).default({ explorationRate: 0.2, minSamples: 5, variants: [] }),
|
}).default({ explorationRate: 0.2, minSamples: 30, responseWindowHours: 48, variants: [] }),
|
||||||
web: z.object({
|
web: z.object({
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
port: z.number().default(3000),
|
port: z.number().default(3000),
|
||||||
/** Prefer WEB_DASHBOARD_TOKEN so secrets do not live in config.yaml. */
|
/** Prefer WEB_DASHBOARD_TOKEN so secrets do not live in config.yaml. */
|
||||||
authToken: z.string().min(8).optional(),
|
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. */
|
/** Dangerous diagnostic endpoints are disabled unless explicitly enabled. */
|
||||||
debugEnabled: z.boolean().default(false),
|
debugEnabled: z.boolean().default(false),
|
||||||
}).default({ enabled: true, port: 3000, debugEnabled: false }),
|
}).default({ enabled: true, port: 3000, debugEnabled: false }),
|
||||||
@@ -195,6 +201,14 @@ export function loadConfig(store?: ConfigPersistence): Config {
|
|||||||
if (!rawConfig.web) rawConfig.web = {};
|
if (!rawConfig.web) rawConfig.web = {};
|
||||||
rawConfig.web.authToken = process.env.WEB_DASHBOARD_TOKEN;
|
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) {
|
if (!rawConfig.openrouter.apiKey) {
|
||||||
throw new Error('OPENROUTER_API_KEY environment variable is required');
|
throw new Error('OPENROUTER_API_KEY environment variable is required');
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ConfigSchema, type Config } from './index.js';
|
|||||||
|
|
||||||
type StoredConfig = Omit<Config, 'database' | 'openrouter' | 'web'> & {
|
type StoredConfig = Omit<Config, 'database' | 'openrouter' | 'web'> & {
|
||||||
openrouter: Omit<Config['openrouter'], 'apiKey'>;
|
openrouter: Omit<Config['openrouter'], 'apiKey'>;
|
||||||
web: Omit<Config['web'], 'authToken'>;
|
web: Omit<Config['web'], 'authToken' | 'agentReadToken' | 'agentControlToken'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,7 +45,12 @@ export class ConfigStore {
|
|||||||
function toStoredConfig(config: Config): StoredConfig {
|
function toStoredConfig(config: Config): StoredConfig {
|
||||||
const { database: _database, ...applicationConfig } = config;
|
const { database: _database, ...applicationConfig } = config;
|
||||||
const { apiKey: _apiKey, ...openrouter } = applicationConfig.openrouter;
|
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 };
|
return { ...applicationConfig, openrouter, web };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +63,12 @@ function withBootstrapSettings(stored: unknown, environment: NodeJS.ProcessEnv):
|
|||||||
...record,
|
...record,
|
||||||
database: { path: environment.DATABASE_PATH ?? './data/reddit-bot.db' },
|
database: { path: environment.DATABASE_PATH ?? './data/reddit-bot.db' },
|
||||||
openrouter: { ...openrouter, apiKey: environment.OPENROUTER_API_KEY },
|
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<string, unknown> {
|
|||||||
: {};
|
: {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionalSecret(value: string | undefined): string | undefined {
|
||||||
|
return value?.trim() ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function validateConfig(raw: unknown): Config {
|
function validateConfig(raw: unknown): Config {
|
||||||
const result = ConfigSchema.safeParse(raw);
|
const result = ConfigSchema.safeParse(raw);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -201,6 +201,22 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
`);
|
`);
|
||||||
},
|
},
|
||||||
() => migrateAccountOwnership(db),
|
() => 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;
|
let currentVersion = db.pragma('user_version', { simple: true }) as number;
|
||||||
|
|||||||
+182
-38
@@ -33,6 +33,11 @@ export interface SentMessageRecord {
|
|||||||
responseCheckedAt: number | null;
|
responseCheckedAt: number | null;
|
||||||
strategyId: string | null;
|
strategyId: string | null;
|
||||||
chatRoomId: string | null;
|
chatRoomId: string | null;
|
||||||
|
experimentId?: string | null;
|
||||||
|
promptVersion?: string | null;
|
||||||
|
modelId?: string | null;
|
||||||
|
selectionReason?: 'explore' | 'exploit' | null;
|
||||||
|
sourceType?: 'image' | 'chat' | null;
|
||||||
subreddit?: string;
|
subreddit?: string;
|
||||||
postTitle?: string;
|
postTitle?: string;
|
||||||
postUrl?: string;
|
postUrl?: string;
|
||||||
@@ -59,6 +64,9 @@ export interface StrategyStats {
|
|||||||
responseRate: number;
|
responseRate: number;
|
||||||
totalImages: number;
|
totalImages: number;
|
||||||
imageRate: number;
|
imageRate: number;
|
||||||
|
pendingSent?: number;
|
||||||
|
responseRateLow?: number;
|
||||||
|
responseRateHigh?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubredditStats {
|
export interface SubredditStats {
|
||||||
@@ -68,6 +76,9 @@ export interface SubredditStats {
|
|||||||
responseRate: number;
|
responseRate: number;
|
||||||
totalImages: number;
|
totalImages: number;
|
||||||
imageRate: number;
|
imageRate: number;
|
||||||
|
pendingSent?: number;
|
||||||
|
responseRateLow?: number;
|
||||||
|
responseRateHigh?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DailyTrend {
|
export interface DailyTrend {
|
||||||
@@ -115,15 +126,17 @@ export class RecipientHistoryRepository {
|
|||||||
|
|
||||||
constructor(db: Database.Database) {
|
constructor(db: Database.Database) {
|
||||||
this.hasSentToStmt = db.prepare(`
|
this.hasSentToStmt = db.prepare(`
|
||||||
SELECT 1
|
SELECT 1 FROM (
|
||||||
FROM sent_messages
|
SELECT recipient FROM sent_messages WHERE recipient = ? COLLATE NOCASE
|
||||||
WHERE recipient = ? COLLATE NOCASE
|
UNION ALL
|
||||||
|
SELECT recipient FROM recipient_suppressions WHERE recipient = ? COLLATE NOCASE
|
||||||
|
)
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
hasSentTo(recipient: string): boolean {
|
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;
|
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: {
|
insert(post: {
|
||||||
id: string;
|
id: string;
|
||||||
subreddit: string;
|
subreddit: string;
|
||||||
@@ -473,8 +494,10 @@ export class MessagesRepository {
|
|||||||
private readonly accountId: string = LEGACY_UNASSIGNED_ACCOUNT_ID,
|
private readonly accountId: string = LEGACY_UNASSIGNED_ACCOUNT_ID,
|
||||||
) {
|
) {
|
||||||
this.insertStmt = db.prepare(`
|
this.insertStmt = db.prepare(`
|
||||||
INSERT INTO sent_messages (account_id, post_id, recipient, subject, message, sent_at, strategy_id, chat_room_id)
|
INSERT INTO sent_messages (
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
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(`
|
this.insertChatStmt = db.prepare(`
|
||||||
@@ -530,7 +553,7 @@ export class MessagesRepository {
|
|||||||
this.markImageStmt = db.prepare(`
|
this.markImageStmt = db.prepare(`
|
||||||
UPDATE sent_messages
|
UPDATE sent_messages
|
||||||
SET got_image = 1, got_response = 1, response_checked_at = ?
|
SET got_image = 1, got_response = 1, response_checked_at = ?
|
||||||
WHERE id = ?
|
WHERE account_id = ? AND id = ?
|
||||||
`);
|
`);
|
||||||
|
|
||||||
this.markCheckedStmt = db.prepare(`
|
this.markCheckedStmt = db.prepare(`
|
||||||
@@ -550,6 +573,11 @@ export class MessagesRepository {
|
|||||||
sentAt: number;
|
sentAt: number;
|
||||||
strategyId?: string | null;
|
strategyId?: string | null;
|
||||||
chatRoomId?: string | null;
|
chatRoomId?: string | null;
|
||||||
|
experimentId?: string | null;
|
||||||
|
promptVersion?: string | null;
|
||||||
|
modelId?: string | null;
|
||||||
|
selectionReason?: 'explore' | 'exploit' | null;
|
||||||
|
sourceType?: 'image' | 'chat' | null;
|
||||||
}): number {
|
}): number {
|
||||||
const result = this.insertStmt.run(
|
const result = this.insertStmt.run(
|
||||||
this.accountId,
|
this.accountId,
|
||||||
@@ -560,6 +588,11 @@ export class MessagesRepository {
|
|||||||
message.sentAt,
|
message.sentAt,
|
||||||
message.strategyId || null,
|
message.strategyId || null,
|
||||||
message.chatRoomId ?? null,
|
message.chatRoomId ?? null,
|
||||||
|
message.experimentId ?? null,
|
||||||
|
message.promptVersion ?? null,
|
||||||
|
message.modelId ?? null,
|
||||||
|
message.selectionReason ?? null,
|
||||||
|
message.sourceType ?? null,
|
||||||
);
|
);
|
||||||
return result.lastInsertRowid as number;
|
return result.lastInsertRowid as number;
|
||||||
}
|
}
|
||||||
@@ -627,7 +660,7 @@ export class MessagesRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
markGotImage(messageId: number): void {
|
markGotImage(messageId: number): void {
|
||||||
this.markImageStmt.run(Date.now(), messageId);
|
this.markImageStmt.run(Date.now(), this.accountId, messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
markChecked(messageId: number): void {
|
markChecked(messageId: number): void {
|
||||||
@@ -638,7 +671,8 @@ export class MessagesRepository {
|
|||||||
return this.hasSentToStmt.get(this.accountId, recipient) !== undefined;
|
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(`
|
const rows = this.db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
strategy_id,
|
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_response = 1 THEN 1 ELSE 0 END) as total_responses,
|
||||||
SUM(CASE WHEN got_image = 1 THEN 1 ELSE 0 END) as total_images
|
SUM(CASE WHEN got_image = 1 THEN 1 ELSE 0 END) as total_images
|
||||||
FROM sent_messages
|
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
|
GROUP BY strategy_id
|
||||||
`).all(this.accountId) as Record<string, unknown>[];
|
`).all(...(matureBefore === undefined ? [this.accountId] : [this.accountId, matureBefore])) as Record<string, unknown>[];
|
||||||
|
|
||||||
return rows.map(row => ({
|
const pending = matureBefore === undefined ? new Map<string, number>() : new Map(
|
||||||
strategyId: row.strategy_id as string,
|
(this.db.prepare(`
|
||||||
totalSent: row.total_sent as number,
|
SELECT strategy_id, COUNT(*) AS total
|
||||||
totalResponses: row.total_responses as number,
|
FROM sent_messages
|
||||||
responseRate: (row.total_sent as number) > 0
|
WHERE account_id = ? AND strategy_id IS NOT NULL AND sent_at > ?
|
||||||
? Math.round(((row.total_responses as number) / (row.total_sent as number)) * 100)
|
GROUP BY strategy_id
|
||||||
: 0,
|
`).all(this.accountId, matureBefore) as Array<{ strategy_id: string; total: number }>)
|
||||||
totalImages: row.total_images as number,
|
.map((row) => [row.strategy_id, row.total]),
|
||||||
imageRate: (row.total_sent as number) > 0
|
);
|
||||||
? Math.round(((row.total_images as number) / (row.total_sent as number)) * 100)
|
|
||||||
: 0,
|
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(`
|
const rows = this.db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
pp.subreddit,
|
pp.subreddit,
|
||||||
@@ -673,23 +715,31 @@ export class MessagesRepository {
|
|||||||
SUM(CASE WHEN sm.got_image = 1 THEN 1 ELSE 0 END) as total_images
|
SUM(CASE WHEN sm.got_image = 1 THEN 1 ELSE 0 END) as total_images
|
||||||
FROM sent_messages sm
|
FROM sent_messages sm
|
||||||
JOIN processed_posts pp ON sm.account_id = pp.account_id AND sm.post_id = pp.id
|
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
|
GROUP BY pp.subreddit
|
||||||
ORDER BY total_sent DESC
|
ORDER BY total_sent DESC
|
||||||
`).all(this.accountId) as Record<string, unknown>[];
|
`).all(...(matureBefore === undefined ? [this.accountId] : [this.accountId, matureBefore])) as Record<string, unknown>[];
|
||||||
|
|
||||||
return rows.map(row => ({
|
const pending = matureBefore === undefined ? new Map<string, number>() : new Map(
|
||||||
subreddit: row.subreddit as string,
|
(this.db.prepare(`
|
||||||
totalSent: row.total_sent as number,
|
SELECT pp.subreddit, COUNT(*) AS total
|
||||||
totalResponses: row.total_responses as number,
|
FROM sent_messages sm
|
||||||
responseRate: (row.total_sent as number) > 0
|
JOIN processed_posts pp ON sm.account_id = pp.account_id AND sm.post_id = pp.id
|
||||||
? Math.round(((row.total_responses as number) / (row.total_sent as number)) * 100)
|
WHERE sm.account_id = ? AND sm.sent_at > ?
|
||||||
: 0,
|
GROUP BY pp.subreddit
|
||||||
totalImages: row.total_images as number,
|
`).all(this.accountId, matureBefore) as Array<{ subreddit: string; total: number }>)
|
||||||
imageRate: (row.total_sent as number) > 0
|
.map((row) => [row.subreddit, row.total]),
|
||||||
? Math.round(((row.total_images as number) / (row.total_sent as number)) * 100)
|
);
|
||||||
: 0,
|
|
||||||
}));
|
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[] {
|
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<string, number | null>;
|
||||||
|
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<string, unknown>): SentMessageRecord {
|
private mapRow(row: Record<string, unknown>): SentMessageRecord {
|
||||||
return {
|
return {
|
||||||
id: row.id as number,
|
id: row.id as number,
|
||||||
@@ -734,6 +828,11 @@ export class MessagesRepository {
|
|||||||
responseCheckedAt: row.response_checked_at as number | null,
|
responseCheckedAt: row.response_checked_at as number | null,
|
||||||
strategyId: (row.strategy_id as string) || null,
|
strategyId: (row.strategy_id as string) || null,
|
||||||
chatRoomId: (row.chat_room_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,
|
subreddit: (row.subreddit as string) || undefined,
|
||||||
postTitle: (row.post_title as string) || undefined,
|
postTitle: (row.post_title as string) || undefined,
|
||||||
postUrl: (row.post_url as string) || undefined,
|
postUrl: (row.post_url as string) || undefined,
|
||||||
@@ -743,6 +842,43 @@ export class MessagesRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function performanceStats<T extends {
|
||||||
|
totalSent: number;
|
||||||
|
totalResponses: number;
|
||||||
|
totalImages: number;
|
||||||
|
}>(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 {
|
export class SuccessfulMessagesRepository {
|
||||||
private insertStmt: Database.Statement;
|
private insertStmt: Database.Statement;
|
||||||
private getRecentStmt: 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;
|
`).get(this.accountId, sentMessageId) as { outcome: ConversationOutcome; response_snippet: string | null } | undefined;
|
||||||
return row ? { outcome: row.outcome, responseSnippet: row.response_snippet } : 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 {
|
export interface RedditUserRecord {
|
||||||
|
|||||||
+73
-2
@@ -291,6 +291,14 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
|||||||
|
|
||||||
await rateLimiter.throttle(
|
await rateLimiter.throttle(
|
||||||
async () => {
|
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)) {
|
if (!recipientDeduplicator.tryReserve(post.author)) {
|
||||||
const reason = 'User already contacted across accounts';
|
const reason = 'User already contacted across accounts';
|
||||||
logger.info(`Skipping u/${post.author} - ${reason.toLowerCase()}`);
|
logger.info(`Skipping u/${post.author} - ${reason.toLowerCase()}`);
|
||||||
@@ -335,6 +343,11 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
|||||||
sentAt: Date.now(),
|
sentAt: Date.now(),
|
||||||
strategyId: processed.strategyId,
|
strategyId: processed.strategyId,
|
||||||
chatRoomId: chat.roomId,
|
chatRoomId: chat.roomId,
|
||||||
|
experimentId: processed.experimentId,
|
||||||
|
promptVersion: processed.promptVersion,
|
||||||
|
modelId: processed.modelId,
|
||||||
|
selectionReason: processed.selectionReason,
|
||||||
|
sourceType: processed.sourceType,
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentCount = currentHourlySendCount(rateLimiter);
|
const currentCount = currentHourlySendCount(rateLimiter);
|
||||||
@@ -410,14 +423,72 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runConcurrentAccountRuntimes(app: AppContainer): Promise<void> {
|
async function runConcurrentAccountRuntimes(app: AppContainer): Promise<void> {
|
||||||
const { logger, accountRuntimeManager, browserClient, db, webServer } = app;
|
const { config, logger, accountRuntimeManager, browserClient, db, webServer } = app;
|
||||||
logger.info(`Running ${accountRuntimeManager.list().length} independent Reddit account runtime(s)`);
|
logger.info(`Running ${accountRuntimeManager.list().length} account runtime(s) with shared post discovery`);
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
let nextScannerIndex = 0;
|
||||||
|
|
||||||
|
const discoverAndDispatch = async (): Promise<void> => {
|
||||||
|
const runtimes = accountRuntimeManager.list()
|
||||||
|
.filter((snapshot) => snapshot.state === 'running')
|
||||||
|
.map((snapshot) => accountRuntimeManager.get(snapshot.accountId))
|
||||||
|
.filter((runtime): runtime is NonNullable<typeof runtime> => 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<void> => {
|
||||||
|
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<void> => {
|
const shutdown = async (): Promise<void> => {
|
||||||
if (shuttingDown) return;
|
if (shuttingDown) return;
|
||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
|
controller.abort();
|
||||||
logger.info('Stopping all Reddit account runtimes...');
|
logger.info('Stopping all Reddit account runtimes...');
|
||||||
|
await discoveryLoop;
|
||||||
await accountRuntimeManager.stopAll();
|
await accountRuntimeManager.stopAll();
|
||||||
webServer?.stop();
|
webServer?.stop();
|
||||||
await browserClient.close();
|
await browserClient.close();
|
||||||
|
|||||||
@@ -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<unknown> {
|
||||||
|
return this.request(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
post(path: string, body?: unknown): Promise<unknown> {
|
||||||
|
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<unknown> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
+48
-25
@@ -2,6 +2,12 @@ import axios, { AxiosInstance } from 'axios';
|
|||||||
import type { BrowserContext } from 'playwright';
|
import type { BrowserContext } from 'playwright';
|
||||||
import type { Config } from '../config/index.js';
|
import type { Config } from '../config/index.js';
|
||||||
import type { Logger } from '../utils/logger.js';
|
import type { Logger } from '../utils/logger.js';
|
||||||
|
import type { RedditRequestScheduler } from './request-coordinator.js';
|
||||||
|
|
||||||
|
const uncoordinatedRequestScheduler: RedditRequestScheduler = {
|
||||||
|
schedule: async <T>(request: () => Promise<T>) => await request(),
|
||||||
|
extendCooldown: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
export class RedditRateLimitError extends Error {
|
export class RedditRateLimitError extends Error {
|
||||||
constructor(public readonly retryAfterMs: number) {
|
constructor(public readonly retryAfterMs: number) {
|
||||||
@@ -105,7 +111,10 @@ export class RedditApiClient {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: Config,
|
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({
|
this.client = axios.create({
|
||||||
baseURL: 'https://www.reddit.com',
|
baseURL: 'https://www.reddit.com',
|
||||||
@@ -143,15 +152,17 @@ export class RedditApiClient {
|
|||||||
if (cookieHeader) headers.Cookie = cookieHeader;
|
if (cookieHeader) headers.Cookie = cookieHeader;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.client.get(
|
const response = await this.scheduleRequest(() => this.client.get(
|
||||||
`/user/${encodeURIComponent(username)}/about.json`,
|
`/user/${encodeURIComponent(username)}/about.json`,
|
||||||
{ params: { raw_json: 1 }, headers },
|
{ params: { raw_json: 1 }, headers },
|
||||||
);
|
));
|
||||||
const id = response.data?.data?.id;
|
const id = response.data?.data?.id;
|
||||||
if (typeof id !== 'string' || id.length === 0) return null;
|
if (typeof id !== 'string' || id.length === 0) return null;
|
||||||
return id.startsWith('t2_') ? id : `t2_${id}`;
|
return id.startsWith('t2_') ? id : `t2_${id}`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 404) return null;
|
if (axios.isAxiosError(error) && error.response?.status === 404) return null;
|
||||||
|
const rateLimitError = this.toRateLimitError(error);
|
||||||
|
if (rateLimitError) throw rateLimitError;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,10 +175,10 @@ export class RedditApiClient {
|
|||||||
headers['Cookie'] = cookieHeader;
|
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 },
|
params: { limit, raw_json: 1 },
|
||||||
headers,
|
headers,
|
||||||
});
|
}));
|
||||||
|
|
||||||
const posts: RedditPost[] = response.data.data.children
|
const posts: RedditPost[] = response.data.data.children
|
||||||
.map((child: { data: RedditPost }) => child.data)
|
.map((child: { data: RedditPost }) => child.data)
|
||||||
@@ -185,12 +196,8 @@ export class RedditApiClient {
|
|||||||
this.logger.warn(`Subreddit r/${subreddit} is private or quarantined`);
|
this.logger.warn(`Subreddit r/${subreddit} is private or quarantined`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
if (error.response?.status === 429) {
|
const rateLimitError = this.toRateLimitError(error);
|
||||||
const retryAfterMs = getRateLimitDelayMs(error.response.headers);
|
if (rateLimitError) throw rateLimitError;
|
||||||
const rateLimitError = new RedditRateLimitError(retryAfterMs);
|
|
||||||
this.logger.warn(rateLimitError.message);
|
|
||||||
throw rateLimitError;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.logger.error(`Error fetching posts from r/${subreddit}:`, error);
|
this.logger.error(`Error fetching posts from r/${subreddit}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -310,10 +317,10 @@ export class RedditApiClient {
|
|||||||
headers['Cookie'] = cookieHeader;
|
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 },
|
params: { raw_json: 1 },
|
||||||
headers,
|
headers,
|
||||||
});
|
}));
|
||||||
|
|
||||||
const userData = response.data?.data;
|
const userData = response.data?.data;
|
||||||
if (userData) {
|
if (userData) {
|
||||||
@@ -331,11 +338,8 @@ export class RedditApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 429) {
|
const rateLimitError = this.toRateLimitError(error);
|
||||||
const rateLimitError = new RedditRateLimitError(getRateLimitDelayMs(error.response.headers));
|
if (rateLimitError) throw rateLimitError;
|
||||||
this.logger.warn(rateLimitError.message);
|
|
||||||
throw rateLimitError;
|
|
||||||
}
|
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||||
this.logger.warn(`User u/${username} profile not found`);
|
this.logger.warn(`User u/${username} profile not found`);
|
||||||
return false;
|
return false;
|
||||||
@@ -350,10 +354,10 @@ export class RedditApiClient {
|
|||||||
const cookieHeader = await this.getCookieHeader();
|
const cookieHeader = await this.getCookieHeader();
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (cookieHeader) headers.Cookie = cookieHeader;
|
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 },
|
params: { limit: 25, raw_json: 1 },
|
||||||
headers,
|
headers,
|
||||||
});
|
}));
|
||||||
const children: RedditListingChild[] = Array.isArray(response.data?.data?.children)
|
const children: RedditListingChild[] = Array.isArray(response.data?.data?.children)
|
||||||
? response.data.data.children
|
? response.data.data.children
|
||||||
: [];
|
: [];
|
||||||
@@ -372,11 +376,8 @@ export class RedditApiClient {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 429) {
|
const rateLimitError = this.toRateLimitError(error);
|
||||||
const rateLimitError = new RedditRateLimitError(getRateLimitDelayMs(error.response.headers));
|
if (rateLimitError) throw rateLimitError;
|
||||||
this.logger.warn(rateLimitError.message);
|
|
||||||
throw rateLimitError;
|
|
||||||
}
|
|
||||||
this.logger.warn(`Failed to check recent activity for u/${username}:`, error);
|
this.logger.warn(`Failed to check recent activity for u/${username}:`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,4 +387,26 @@ export class RedditApiClient {
|
|||||||
getPostUrl(post: RedditPost): string {
|
getPostUrl(post: RedditPost): string {
|
||||||
return `https://www.reddit.com${post.permalink}`;
|
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<T>(request: () => Promise<T>): Promise<T> {
|
||||||
|
return await this.requestScheduler.schedule(async () => {
|
||||||
|
try {
|
||||||
|
return await request();
|
||||||
|
} catch (error) {
|
||||||
|
const rateLimitError = this.toRateLimitError(error);
|
||||||
|
if (rateLimitError) throw rateLimitError;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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<T>(request: () => Promise<T>): Promise<T>;
|
||||||
|
extendCooldown(retryAfterMs: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedditRequestCoordinatorDependencies {
|
||||||
|
now?: () => number;
|
||||||
|
sleep?: (ms: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MIN_REQUEST_INTERVAL_MS = 6_500;
|
||||||
|
const COOLDOWN_BUFFER_MS = 1_000;
|
||||||
|
|
||||||
|
export class RedditRequestCoordinator implements RedditRequestScheduler {
|
||||||
|
private tail: Promise<void> = Promise.resolve();
|
||||||
|
private nextRequestAt = 0;
|
||||||
|
private readonly now: () => number;
|
||||||
|
private readonly sleep: (ms: number) => Promise<void>;
|
||||||
|
|
||||||
|
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<T>(request: () => Promise<T>): Promise<T> {
|
||||||
|
let release: () => void = () => undefined;
|
||||||
|
const previous = this.tail;
|
||||||
|
this.tail = new Promise<void>((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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { Logger } from '../../utils/logger.js';
|
import type { Logger } from '../../utils/logger.js';
|
||||||
import type {
|
import type {
|
||||||
|
ConversationOutcomesRepository,
|
||||||
MessagesRepository,
|
MessagesRepository,
|
||||||
RedditUsersRepository,
|
RedditUsersRepository,
|
||||||
SentMessageRecord,
|
SentMessageRecord,
|
||||||
SuccessfulMessagesRepository,
|
SuccessfulMessagesRepository,
|
||||||
} from '../../database/repositories.js';
|
} from '../../database/repositories.js';
|
||||||
|
import { recordConversationReply } from '../conversation-outcome-recorder.js';
|
||||||
import type { MediaService } from '../media.js';
|
import type { MediaService } from '../media.js';
|
||||||
import { ChatCache } from './cache.js';
|
import { ChatCache } from './cache.js';
|
||||||
import {
|
import {
|
||||||
@@ -151,6 +153,7 @@ export class ChatService {
|
|||||||
private messagesRepo?: MessagesRepository,
|
private messagesRepo?: MessagesRepository,
|
||||||
private redditUsersRepo?: RedditUsersRepository,
|
private redditUsersRepo?: RedditUsersRepository,
|
||||||
private accountAuth?: AccountAuthGate,
|
private accountAuth?: AccountAuthGate,
|
||||||
|
private outcomesRepo?: ConversationOutcomesRepository,
|
||||||
) {
|
) {
|
||||||
this.cache = cache ?? new ChatCache(logger);
|
this.cache = cache ?? new ChatCache(logger);
|
||||||
if (this.redditUsersRepo) {
|
if (this.redditUsersRepo) {
|
||||||
@@ -1007,6 +1010,7 @@ export class ChatService {
|
|||||||
(message) => !message.isSelf && (message.media?.length ?? 0) > 0,
|
(message) => !message.isSelf && (message.media?.length ?? 0) > 0,
|
||||||
);
|
);
|
||||||
let performanceMessage: SentMessageRecord | undefined;
|
let performanceMessage: SentMessageRecord | undefined;
|
||||||
|
let firstAttributedReply = false;
|
||||||
|
|
||||||
if (firstSelf && chat.roomId && this.messagesRepo) {
|
if (firstSelf && chat.roomId && this.messagesRepo) {
|
||||||
try {
|
try {
|
||||||
@@ -1017,7 +1021,7 @@ export class ChatService {
|
|||||||
if (existing && hasPerformanceContext) {
|
if (existing && hasPerformanceContext) {
|
||||||
performanceMessage = existing;
|
performanceMessage = existing;
|
||||||
if (hasReply && !existing.gotResponse) {
|
if (hasReply && !existing.gotResponse) {
|
||||||
this.messagesRepo.markGotResponse(existing.id);
|
firstAttributedReply = true;
|
||||||
}
|
}
|
||||||
if (hasReceivedImage && !existing.gotImage) {
|
if (hasReceivedImage && !existing.gotImage) {
|
||||||
this.messagesRepo.markGotImage(existing.id);
|
this.messagesRepo.markGotImage(existing.id);
|
||||||
@@ -1028,19 +1032,26 @@ export class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (firstSelf && hasReply && performanceMessage && this.successfulMessagesRepo) {
|
if (firstSelf && hasReply && performanceMessage && this.successfulMessagesRepo && this.outcomesRepo && this.messagesRepo) {
|
||||||
if (!this.successfulMessagesRepo.existsByMessage(firstSelf.text)) {
|
const firstReply = chat.messages.find((m) => !m.isSelf);
|
||||||
const firstReply = chat.messages.find((m) => !m.isSelf);
|
const selfCount = chat.messages.filter((m) => m.isSelf).length;
|
||||||
try {
|
const otherCount = chat.messages.length - selfCount;
|
||||||
this.successfulMessagesRepo.insert({
|
try {
|
||||||
sentMessageId: performanceMessage.id,
|
recordConversationReply(
|
||||||
originalMessage: firstSelf.text,
|
performanceMessage,
|
||||||
imageAnalysis: performanceMessage.postImageAnalysis ?? null,
|
firstReply?.text.slice(0, 200) ?? '',
|
||||||
postTitle: performanceMessage.postTitle ?? null,
|
{
|
||||||
responseSnippet: firstReply?.text.slice(0, 200) || null,
|
messageCount: chat.messages.length,
|
||||||
subreddit: performanceMessage.subreddit ?? null,
|
reciprocal: selfCount >= 2 && otherCount >= 2,
|
||||||
});
|
},
|
||||||
this.logger.info(`[chat] stored successful chat example from ${chat.username}`);
|
{
|
||||||
|
messages: this.messagesRepo,
|
||||||
|
successful: this.successfulMessagesRepo,
|
||||||
|
outcomes: this.outcomesRepo,
|
||||||
|
logger: this.logger,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (firstAttributedReply) {
|
||||||
try {
|
try {
|
||||||
this.newReplyListener?.({
|
this.newReplyListener?.({
|
||||||
conversationId: chat.roomId,
|
conversationId: chat.roomId,
|
||||||
@@ -1051,10 +1062,13 @@ export class ChatService {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn('[chat] newReplyListener threw:', 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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,15 +1,25 @@
|
|||||||
export const CONVERSATION_OUTCOMES = ['engaged', 'declined', 'boundary', 'upsell', 'unknown'] as const;
|
export const CONVERSATION_OUTCOMES = ['engaged', 'declined', 'boundary', 'upsell', 'unknown'] as const;
|
||||||
export type ConversationOutcome = (typeof CONVERSATION_OUTCOMES)[number];
|
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:
|
* Deterministic first-pass outcome classifier. It is deliberately conservative:
|
||||||
* only replies with no decline, boundary, or sales signal become learning examples.
|
* 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();
|
const text = reply.toLowerCase();
|
||||||
if (/\b(onlyfans|fansly|manyvids|link in bio|cash\.?app|venmo)\b/.test(text)) return 'upsell';
|
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(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 (/\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';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,9 @@ export class MonitorService {
|
|||||||
// to two profile requests for every otherwise-valid post in the listing.
|
// to two profile requests for every otherwise-valid post in the listing.
|
||||||
const newestFirst = [...posts].sort((a, b) => b.created_utc - a.created_utc);
|
const newestFirst = [...posts].sort((a, b) => b.created_utc - a.created_utc);
|
||||||
for (const post of newestFirst) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export interface ProcessedPost {
|
|||||||
detectedGender: DetectedGender;
|
detectedGender: DetectedGender;
|
||||||
generatedMessage: string;
|
generatedMessage: string;
|
||||||
strategyId: string | null;
|
strategyId: string | null;
|
||||||
|
experimentId: string | null;
|
||||||
|
promptVersion: string | null;
|
||||||
|
selectionReason: 'explore' | 'exploit' | null;
|
||||||
|
modelId: string;
|
||||||
|
sourceType: 'image' | 'chat';
|
||||||
toneId: ToneKey;
|
toneId: ToneKey;
|
||||||
scripted: boolean;
|
scripted: boolean;
|
||||||
}
|
}
|
||||||
@@ -213,6 +218,11 @@ export class ProcessorService {
|
|||||||
detectedGender: analysis.detectedGender,
|
detectedGender: analysis.detectedGender,
|
||||||
generatedMessage: message,
|
generatedMessage: message,
|
||||||
strategyId: selectedStrategy?.id || null,
|
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,
|
toneId: tone.profile.id,
|
||||||
scripted: tone.scripted !== null,
|
scripted: tone.scripted !== null,
|
||||||
};
|
};
|
||||||
@@ -290,6 +300,11 @@ export class ProcessorService {
|
|||||||
detectedGender: detectedGender ?? 'unknown',
|
detectedGender: detectedGender ?? 'unknown',
|
||||||
generatedMessage: message,
|
generatedMessage: message,
|
||||||
strategyId: selectedStrategy?.id || null,
|
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,
|
toneId: tone.profile.id,
|
||||||
scripted: tone.scripted !== null,
|
scripted: tone.scripted !== null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
RecipientHistoryRepository,
|
RecipientHistoryRepository,
|
||||||
} from '../database/repositories.js';
|
} from '../database/repositories.js';
|
||||||
import { AccountContentRepository } from '../database/account-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 { OpenRouterClient } from '../ai/openrouter.js';
|
||||||
import { MonitorService } from './monitor.js';
|
import { MonitorService } from './monitor.js';
|
||||||
import { ProcessorService, isSkip } from './processor.js';
|
import { ProcessorService, isSkip } from './processor.js';
|
||||||
@@ -52,8 +53,9 @@ export function createConcreteRedditAccountRuntimeFactory(
|
|||||||
const recipientDeduplicator = new RecipientDeduplicator(
|
const recipientDeduplicator = new RecipientDeduplicator(
|
||||||
new RecipientHistoryRepository(options.db),
|
new RecipientHistoryRepository(options.db),
|
||||||
);
|
);
|
||||||
|
const redditRequestCoordinator = new RedditRequestCoordinator();
|
||||||
return new RedditAccountRuntimeFactory(
|
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,
|
{ config, logger, db, isBotRunning = () => true }: ConcreteRuntimeFactoryOptions,
|
||||||
session: VerifiedAccountSession,
|
session: VerifiedAccountSession,
|
||||||
recipientDeduplicator: RecipientDeduplicator,
|
recipientDeduplicator: RecipientDeduplicator,
|
||||||
|
redditRequestCoordinator: RedditRequestCoordinator,
|
||||||
): Promise<RedditAccountRuntime> {
|
): Promise<RedditAccountRuntime> {
|
||||||
const browserProfileDir = accountBrowserProfileDir(
|
const browserProfileDir = accountBrowserProfileDir(
|
||||||
path.resolve(process.cwd(), config.browser.sessionDir),
|
path.resolve(process.cwd(), config.browser.sessionDir),
|
||||||
@@ -107,7 +110,7 @@ async function buildRuntime(
|
|||||||
const accountContentRepo = new AccountContentRepository(db);
|
const accountContentRepo = new AccountContentRepository(db);
|
||||||
const activityLogRepo = new ActivityLogRepository(db, session.accountId);
|
const activityLogRepo = new ActivityLogRepository(db, session.accountId);
|
||||||
|
|
||||||
const redditApi = new RedditApiClient(accountConfig, logger);
|
const redditApi = new RedditApiClient(accountConfig, logger, redditRequestCoordinator);
|
||||||
redditApi.setBrowserContext(browserClient.getContext()!);
|
redditApi.setBrowserContext(browserClient.getContext()!);
|
||||||
const chatPage = await browserClient.getContext()!.newPage();
|
const chatPage = await browserClient.getContext()!.newPage();
|
||||||
const aiClient = new OpenRouterClient(accountConfig, logger);
|
const aiClient = new OpenRouterClient(accountConfig, logger);
|
||||||
@@ -135,7 +138,7 @@ async function buildRuntime(
|
|||||||
const chatService = new ChatService(
|
const chatService = new ChatService(
|
||||||
matrixClient, logger, successfulMessagesRepo, mediaService,
|
matrixClient, logger, successfulMessagesRepo, mediaService,
|
||||||
new MatrixTokenRefresher(), usernameResolver, chatCache,
|
new MatrixTokenRefresher(), usernameResolver, chatCache,
|
||||||
messagesRepo, redditUsersRepo, accountAuth,
|
messagesRepo, redditUsersRepo, accountAuth, conversationOutcomesRepo,
|
||||||
);
|
);
|
||||||
await chatService.loadCache();
|
await chatService.loadCache();
|
||||||
const mediaSyncService = new MediaSyncService(chatService, logger, accountConfig.media.syncIntervalMs, accountAuth);
|
const mediaSyncService = new MediaSyncService(chatService, logger, accountConfig.media.syncIntervalMs, accountAuth);
|
||||||
@@ -227,23 +230,23 @@ class AccountRuntimeLoop {
|
|||||||
nextResponseCheckAt = Date.now() + responseInterval;
|
nextResponseCheckAt = Date.now() + responseInterval;
|
||||||
await this.resources.responseChecker.checkForResponses();
|
await this.resources.responseChecker.checkForResponses();
|
||||||
}
|
}
|
||||||
if (!this.resources.chatService.getInvitationCooldown()) {
|
|
||||||
await this.processPosts(signal);
|
|
||||||
}
|
|
||||||
await delay(this.config.reddit.pollIntervalMs, signal);
|
await delay(this.config.reddit.pollIntervalMs, signal);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (signal.aborted) return;
|
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);
|
this.logger.error(`Account runtime loop failed for ${this.resources.accountAuth.getSnapshot().redditUsername}:`, error);
|
||||||
await delay(60_000, signal);
|
await delay(60_000, signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processPosts(signal: AbortSignal): Promise<void> {
|
async dispatchPost(post: RedditPost, signal?: AbortSignal): Promise<void> {
|
||||||
const { monitorService, postsRepo, messagesRepo, redditApi, processorService, rateLimiter, chatService, responseChecker } = this.resources;
|
const { postsRepo, messagesRepo, redditApi, processorService, rateLimiter, chatService, responseChecker } = this.resources;
|
||||||
const posts = await monitorService.getUnprocessedPosts();
|
if (signal?.aborted) return;
|
||||||
for (const post of posts) {
|
try {
|
||||||
if (signal.aborted) return;
|
|
||||||
const subreddit = this.config.reddit.subreddits.find((entry) => entry.name.toLowerCase() === post.subreddit.toLowerCase());
|
const subreddit = this.config.reddit.subreddits.find((entry) => entry.name.toLowerCase() === post.subreddit.toLowerCase());
|
||||||
const isChat = subreddit?.type === 'chat';
|
const isChat = subreddit?.type === 'chat';
|
||||||
const imageUrl = isChat ? null : redditApi.getImageUrl(post);
|
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,
|
id: post.id, subreddit: post.subreddit, author: post.author, title: post.title,
|
||||||
postUrl: redditApi.getPostUrl(post), imageUrl, processedAt: Date.now(),
|
postUrl: redditApi.getPostUrl(post), imageUrl, processedAt: Date.now(),
|
||||||
});
|
});
|
||||||
try {
|
// Seller screening is performed once by the shared MonitorService before
|
||||||
if (
|
// assignment. Do not repeat its profile request for every recipient.
|
||||||
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;
|
|
||||||
}
|
|
||||||
processorService.setSuccessfulExamples(responseChecker.getSuccessfulExamples(3, post.subreddit));
|
processorService.setSuccessfulExamples(responseChecker.getSuccessfulExamples(3, post.subreddit));
|
||||||
const processed = await processorService.processPost(post);
|
const processed = await processorService.processPost(post);
|
||||||
if (isSkip(processed)) {
|
if (isSkip(processed)) {
|
||||||
postsRepo.setError(post.id, processed.skipReason);
|
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 });
|
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);
|
postsRepo.updateAnalysis(post.id, processed.imageAnalysis, processed.generatedMessage);
|
||||||
await rateLimiter.throttle(async () => {
|
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)) {
|
if (!this.recipientDeduplicator.tryReserve(post.author)) {
|
||||||
postsRepo.setError(post.id, 'User already contacted across accounts');
|
postsRepo.setError(post.id, 'User already contacted across accounts');
|
||||||
this.resources.activityLogRepo.insert({
|
this.resources.activityLogRepo.insert({
|
||||||
@@ -295,6 +299,9 @@ class AccountRuntimeLoop {
|
|||||||
postId: post.id, recipient: post.author, subject: post.author,
|
postId: post.id, recipient: post.author, subject: post.author,
|
||||||
message: processed.generatedMessage, sentAt: Date.now(),
|
message: processed.generatedMessage, sentAt: Date.now(),
|
||||||
strategyId: processed.strategyId, chatRoomId: chat.roomId,
|
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({
|
this.resources.activityLogRepo.insert({
|
||||||
timestamp: Date.now(), type: 'sent', message: `Sent message to u/${post.author} (r/${post.subreddit})`,
|
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);
|
void chatService.refreshTopChats(5);
|
||||||
return true;
|
return true;
|
||||||
}, (success) => success, signal);
|
}, (success) => success, signal);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (signal.aborted) return;
|
if (signal?.aborted) return;
|
||||||
postsRepo.setError(post.id, error instanceof Error ? error.message : String(error));
|
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.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);
|
this.logger.error(`Account runtime failed processing post ${post.id}:`, error);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type { MediaSyncService } from './media-sync.js';
|
|||||||
import type { MonitorService } from './monitor.js';
|
import type { MonitorService } from './monitor.js';
|
||||||
import type { ProcessorService } from './processor.js';
|
import type { ProcessorService } from './processor.js';
|
||||||
import type { ResponseCheckerService } from './response-checker.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. */
|
/** Credentials passed to a factory. They must never be exposed by list/snapshot APIs. */
|
||||||
export interface VerifiedAccountSession {
|
export interface VerifiedAccountSession {
|
||||||
@@ -80,6 +81,8 @@ export interface RedditAccountRuntimeLifecycle {
|
|||||||
start(): void | Promise<void>;
|
start(): void | Promise<void>;
|
||||||
/** Stop and await this account's schedules/loop, then close its browser. */
|
/** Stop and await this account's schedules/loop, then close its browser. */
|
||||||
stop(): void | Promise<void>;
|
stop(): void | Promise<void>;
|
||||||
|
/** Process a post assigned by the shared discovery scheduler. */
|
||||||
|
dispatchPost?(post: RedditPost, signal?: AbortSignal): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RedditAccountRuntimeState =
|
export type RedditAccountRuntimeState =
|
||||||
@@ -172,6 +175,24 @@ export class RedditAccountRuntime {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async canAcceptPost(): Promise<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<void>): Promise<void> {
|
private enqueue(operation: () => Promise<void>): Promise<void> {
|
||||||
const result = this.transition.then(operation, operation);
|
const result = this.transition.then(operation, operation);
|
||||||
this.transition = result.catch(() => undefined);
|
this.transition = result.catch(() => undefined);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Page } from 'playwright';
|
|||||||
import type { Logger } from '../utils/logger.js';
|
import type { Logger } from '../utils/logger.js';
|
||||||
import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord, ConversationOutcomesRepository } from '../database/repositories.js';
|
import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord, ConversationOutcomesRepository } from '../database/repositories.js';
|
||||||
import type { PostsRepository } 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';
|
import { denyAccountAuth, type AccountAuthGate } from './account-auth-context.js';
|
||||||
|
|
||||||
export interface ResponseCheckResult {
|
export interface ResponseCheckResult {
|
||||||
@@ -68,25 +68,28 @@ export class ResponseCheckerService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.messagesRepo.markGotResponse(message.id);
|
|
||||||
|
|
||||||
// Get the original post info
|
|
||||||
const post = message.postId ? this.postsRepo.getById(message.postId) : undefined;
|
const post = message.postId ? this.postsRepo.getById(message.postId) : undefined;
|
||||||
|
const attributedMessage = {
|
||||||
const outcome = classifyConversationOutcome(result.responseSnippet ?? '');
|
...message,
|
||||||
this.outcomesRepo?.record(message.id, outcome, result.responseSnippet ?? null);
|
subreddit: message.subreddit ?? post?.subreddit,
|
||||||
if (outcome === 'engaged') {
|
postTitle: message.postTitle ?? post?.title,
|
||||||
this.successfulMessagesRepo.insert({
|
postImageAnalysis: message.postImageAnalysis ?? post?.imageAnalysis ?? undefined,
|
||||||
sentMessageId: message.id,
|
};
|
||||||
originalMessage: message.message,
|
if (this.outcomesRepo) {
|
||||||
imageAnalysis: post?.imageAnalysis || null,
|
recordConversationReply(
|
||||||
postTitle: post?.title || null,
|
attributedMessage,
|
||||||
responseSnippet: result.responseSnippet || null,
|
result.responseSnippet ?? '',
|
||||||
subreddit: post?.subreddit || null,
|
{ messageCount: 2, reciprocal: false },
|
||||||
});
|
{
|
||||||
this.logger.info(`Engaged response from u/${message.recipient}; stored for learning.`);
|
messages: this.messagesRepo,
|
||||||
|
successful: this.successfulMessagesRepo,
|
||||||
|
outcomes: this.outcomesRepo,
|
||||||
|
logger: this.logger,
|
||||||
|
},
|
||||||
|
);
|
||||||
} else {
|
} 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 {
|
} else {
|
||||||
// Just mark as checked
|
// Just mark as checked
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ export interface SelectedStrategy {
|
|||||||
id: string;
|
id: string;
|
||||||
promptOverride: string;
|
promptOverride: string;
|
||||||
reason: 'exploit' | 'explore';
|
reason: 'exploit' | 'explore';
|
||||||
|
experimentId?: string;
|
||||||
|
promptVersion?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StrategySelector {
|
export class StrategySelector {
|
||||||
private variants: Config['strategies']['variants'];
|
private variants: Config['strategies']['variants'];
|
||||||
private explorationRate: number;
|
private explorationRate: number;
|
||||||
private minSamples: number;
|
private minSamples: number;
|
||||||
|
private responseWindowMs: number;
|
||||||
|
private section: 'strategies' | 'chatStrategies';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private config: Config,
|
private config: Config,
|
||||||
@@ -19,10 +23,12 @@ export class StrategySelector {
|
|||||||
private logger: Logger,
|
private logger: Logger,
|
||||||
section: 'strategies' | 'chatStrategies' = 'strategies',
|
section: 'strategies' | 'chatStrategies' = 'strategies',
|
||||||
) {
|
) {
|
||||||
|
this.section = section;
|
||||||
const cfg = config[section];
|
const cfg = config[section];
|
||||||
this.variants = cfg.variants;
|
this.variants = cfg.variants;
|
||||||
this.explorationRate = cfg.explorationRate;
|
this.explorationRate = cfg.explorationRate;
|
||||||
this.minSamples = cfg.minSamples;
|
this.minSamples = cfg.minSamples;
|
||||||
|
this.responseWindowMs = cfg.responseWindowHours * 60 * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectStrategy(): SelectedStrategy | null {
|
selectStrategy(): SelectedStrategy | null {
|
||||||
@@ -32,13 +38,13 @@ export class StrategySelector {
|
|||||||
|
|
||||||
// Exploration: random strategy
|
// Exploration: random strategy
|
||||||
if (Math.random() < this.explorationRate) {
|
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}`);
|
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
|
// 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]));
|
const statsMap = new Map(stats.map(s => [s.strategyId, s]));
|
||||||
|
|
||||||
// Find strategies with insufficient samples - prioritize exploring them
|
// Find strategies with insufficient samples - prioritize exploring them
|
||||||
@@ -48,9 +54,9 @@ export class StrategySelector {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (unexplored.length > 0) {
|
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}`);
|
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
|
// 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}`);
|
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 })[] {
|
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]));
|
const statsMap = new Map(dbStats.map(s => [s.strategyId, s]));
|
||||||
|
|
||||||
// Include all configured variants, even if they have no data yet
|
// Include all configured variants, even if they have no data yet
|
||||||
@@ -102,4 +108,30 @@ export class StrategySelector {
|
|||||||
hasStrategies(): boolean {
|
hasStrategies(): boolean {
|
||||||
return this.variants.length > 0;
|
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<T extends { weight?: number }>(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';
|
||||||
@@ -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<typeof AgentJobRequestSchema>;
|
||||||
|
export type AgentJob = z.infer<typeof AgentJobSchema>;
|
||||||
|
export type ControlResponse = z.infer<typeof ControlResponseSchema>;
|
||||||
@@ -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<unknown>;
|
||||||
|
|
||||||
|
export class AgentJobManager {
|
||||||
|
private readonly jobs = new Map<string, AgentJob>();
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Omit<ApiOperation, 'operationId' | 'method' | 'path' | 'summary' | 'tags'>> = {},
|
||||||
|
): 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<string, unknown> | undefined {
|
||||||
|
return schema ? z.toJSONSchema(schema, { target: 'draft-7' }) as Record<string, unknown> : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOpenApiDocument(): Record<string, unknown> {
|
||||||
|
const paths: Record<string, Record<string, unknown>> = {};
|
||||||
|
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),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
+16
-1
@@ -5,6 +5,11 @@ import { z } from 'zod';
|
|||||||
const SessionRequestSchema = z.object({ token: z.string().min(1).max(4096) }).strict();
|
const SessionRequestSchema = z.object({ token: z.string().min(1).max(4096) }).strict();
|
||||||
const SESSION_COOKIE = 'reddit_bot_dashboard_session';
|
const SESSION_COOKIE = 'reddit_bot_dashboard_session';
|
||||||
|
|
||||||
|
export interface AgentTokenOptions {
|
||||||
|
readToken?: string;
|
||||||
|
controlToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
function tokenDigest(token: string): Buffer {
|
function tokenDigest(token: string): Buffer {
|
||||||
return createHash('sha256').update(token, 'utf8').digest();
|
return createHash('sha256').update(token, 'utf8').digest();
|
||||||
}
|
}
|
||||||
@@ -34,13 +39,23 @@ function requestToken(authorization: string | undefined): string | undefined {
|
|||||||
return bearer?.[1];
|
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');
|
if (!token.trim()) throw new Error('A non-empty dashboard authentication token is required');
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
if (req.path === '/auth/session' || req.path === '/auth/login') return next();
|
if (req.path === '/auth/session' || req.path === '/auth/login') return next();
|
||||||
const supplied = requestToken(req.header('authorization'))
|
const supplied = requestToken(req.header('authorization'))
|
||||||
?? parseCookies(req.header('cookie'))[SESSION_COOKIE];
|
?? parseCookies(req.header('cookie'))[SESSION_COOKIE];
|
||||||
if (matchesToken(supplied, token)) return next();
|
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')) {
|
if (!req.path.startsWith('/api') && !req.path.startsWith('/media')) {
|
||||||
res.redirect(302, '/auth/login');
|
res.redirect(302, '/auth/login');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -89,9 +89,15 @@ export class LogBroadcaster {
|
|||||||
/** Express handler for `/api/logs?limit=N`. */
|
/** Express handler for `/api/logs?limit=N`. */
|
||||||
recent = (req: Request, res: Response): void => {
|
recent = (req: Request, res: Response): void => {
|
||||||
const limit = parseInt(req.query.limit as string) || 100;
|
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 {
|
closeAll(): void {
|
||||||
for (const client of this.clients) {
|
for (const client of this.clients) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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>): number | null {
|
||||||
|
const present = values.filter((value): value is number => value !== null);
|
||||||
|
return present.length > 0 ? Math.max(...present) : null;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { WebContext } from '../context.js';
|
|||||||
|
|
||||||
export function createAnalyticsRouter(ctx: WebContext): Router {
|
export function createAnalyticsRouter(ctx: WebContext): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
const matureBefore = (): number => Date.now() - ctx.config.strategies.responseWindowHours * 60 * 60 * 1000;
|
||||||
|
|
||||||
router.get('/timers', (_req, res) => {
|
router.get('/timers', (_req, res) => {
|
||||||
if (!ctx.services.rateLimiter) {
|
if (!ctx.services.rateLimiter) {
|
||||||
@@ -13,17 +14,24 @@ export function createAnalyticsRouter(ctx: WebContext): Router {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.get('/analytics/by-subreddit', (_req, res) => {
|
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) => {
|
router.get('/analytics/by-strategy', (_req, res) => {
|
||||||
if (ctx.services.strategySelector) {
|
if (ctx.services.strategySelector) {
|
||||||
res.json(ctx.services.strategySelector.getStats());
|
res.json(ctx.services.strategySelector.getStats());
|
||||||
} else {
|
} 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) => {
|
router.get('/analytics/trend', (req, res) => {
|
||||||
const days = parseInt(req.query.days as string) || 30;
|
const days = parseInt(req.query.days as string) || 30;
|
||||||
res.json(ctx.repos.messages.getResponseRateTrend(days));
|
res.json(ctx.repos.messages.getResponseRateTrend(days));
|
||||||
|
|||||||
@@ -1,28 +1,20 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import type { WebContext } from '../context.js';
|
import type { WebContext } from '../context.js';
|
||||||
|
import { setBotRunning } from '../bot-control.js';
|
||||||
|
|
||||||
export function createControlRouter(ctx: WebContext): Router {
|
export function createControlRouter(ctx: WebContext): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post('/start', (_req, res) => {
|
router.post('/start', (_req, res) => {
|
||||||
if (!ctx.services.accountAuth?.isFullyAuthenticated()) {
|
const result = setBotRunning(ctx, true);
|
||||||
ctx.logger.warn('Bot start blocked until full account authentication');
|
res.status(result.success ? 200 : 409).json(result.success
|
||||||
res.status(409).json({ success: false, running: false, authenticated: false });
|
? { success: true, running: result.running }
|
||||||
return;
|
: { success: false, running: false, authenticated: false });
|
||||||
}
|
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/stop', (_req, res) => {
|
router.post('/stop', (_req, res) => {
|
||||||
ctx.setBotRunning(false);
|
const result = setBotRunning(ctx, false);
|
||||||
ctx.activity.push('skipped', 'Bot stopped by user');
|
res.json({ success: true, running: result.running });
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
+7
-1
@@ -40,6 +40,7 @@ import { createConfigRouter } from './routes/config.js';
|
|||||||
import { createLoginRouter } from './routes/login.js';
|
import { createLoginRouter } from './routes/login.js';
|
||||||
import { createDashboardAuth, createDashboardSessionRouter } from './auth.js';
|
import { createDashboardAuth, createDashboardSessionRouter } from './auth.js';
|
||||||
import { resolveDashboardAssets } from './dashboard-assets.js';
|
import { resolveDashboardAssets } from './dashboard-assets.js';
|
||||||
|
import { createAgentRouter, createHealthRouter } from './routes/agent.js';
|
||||||
|
|
||||||
export type { BotStats, ActivityItem, LogEntry, RuntimeConfig } from './types.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');
|
throw new Error('Web dashboard requires web.authToken or WEB_DASHBOARD_TOKEN');
|
||||||
}
|
}
|
||||||
this.app.use(express.json({ limit: '64kb' }));
|
this.app.use(express.json({ limit: '64kb' }));
|
||||||
|
this.app.use(createHealthRouter());
|
||||||
this.app.use(createDashboardSessionRouter(dashboardToken));
|
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);
|
const dashboardAssets = resolveDashboardAssets(this.workspaceDirectory);
|
||||||
|
|
||||||
@@ -140,6 +145,7 @@ export class WebServer {
|
|||||||
this.app.use(express.static(dashboardAssets.directory, { index: false }));
|
this.app.use(express.static(dashboardAssets.directory, { index: false }));
|
||||||
|
|
||||||
const ctx = this.context();
|
const ctx = this.context();
|
||||||
|
this.app.use('/api', createAgentRouter(ctx));
|
||||||
this.app.use('/api', createStatsRouter(ctx));
|
this.app.use('/api', createStatsRouter(ctx));
|
||||||
this.app.use('/api', createChatsRouter(ctx));
|
this.app.use('/api', createChatsRouter(ctx));
|
||||||
this.app.use('/api', createConversationsRouter(ctx));
|
this.app.use('/api', createConversationsRouter(ctx));
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, Record<string, Record<string, unknown>>> };
|
||||||
|
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<string, unknown>)['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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -85,13 +85,9 @@ describe('ChatService progressive full backfill', () => {
|
|||||||
expect(repos.messagesRepo.getResponseRateTrend()).toEqual([
|
expect(repos.messagesRepo.getResponseRateTrend()).toEqual([
|
||||||
expect.objectContaining({ totalSent: 1, totalResponses: 1, responseRate: 100 }),
|
expect.objectContaining({ totalSent: 1, totalResponses: 1, responseRate: 100 }),
|
||||||
]);
|
]);
|
||||||
expect(repos.successfulMessagesRepo.getRecent()).toEqual([
|
// A single reply counts toward response performance, but the
|
||||||
expect.objectContaining({
|
// reciprocal-engagement policy excludes it from learning examples.
|
||||||
originalMessage: 'Hello Alice',
|
expect(repos.successfulMessagesRepo.getRecent()).toEqual([]);
|
||||||
postTitle: 'Source post',
|
|
||||||
subreddit: 'example',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -42,11 +42,15 @@ describe('ConfigStore', () => {
|
|||||||
const config = getTestConfig();
|
const config = getTestConfig();
|
||||||
config.openrouter.apiKey = 'database-must-not-contain-this';
|
config.openrouter.apiKey = 'database-must-not-contain-this';
|
||||||
config.web.authToken = 'also-secret';
|
config.web.authToken = 'also-secret';
|
||||||
|
config.web.agentReadToken = 'read-agent-secret';
|
||||||
|
config.web.agentControlToken = 'control-agent-secret';
|
||||||
store.save(config);
|
store.save(config);
|
||||||
|
|
||||||
const row = db.prepare('SELECT config_json FROM app_config WHERE id = 1').get() as { config_json: string };
|
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('database-must-not-contain-this');
|
||||||
expect(row.config_json).not.toContain('also-secret');
|
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');
|
expect(JSON.parse(row.config_json)).not.toHaveProperty('database');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -221,7 +221,8 @@ describe('ConfigSchema', () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
expect(result.data.strategies.explorationRate).toBe(0.2);
|
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([]);
|
expect(result.data.strategies.variants).toEqual([]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,11 +3,18 @@ import { classifyConversationOutcome } from '../src/services/conversation-outcom
|
|||||||
|
|
||||||
describe('conversation outcome classification', () => {
|
describe('conversation outcome classification', () => {
|
||||||
it.each([
|
it.each([
|
||||||
['That sounds fun, tell me more', 'engaged'],
|
['That sounds fun, tell me more', 'unknown'],
|
||||||
['No thanks, not interested', 'declined'],
|
['No thanks, not interested', 'declined'],
|
||||||
['Check my OnlyFans link in bio', 'upsell'],
|
['Check my OnlyFans link in bio', 'upsell'],
|
||||||
['Stop messaging me', 'boundary'],
|
['Stop messaging me', 'boundary'],
|
||||||
] as const)('classifies %s as %s', (reply, expected) => {
|
] as const)('classifies %s as %s', (reply, expected) => {
|
||||||
expect(classifyConversationOutcome(reply)).toBe(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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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(<PerformancePage />);
|
renderPage(<PerformancePage />);
|
||||||
|
|
||||||
expect(await screen.findByRole('heading', { name: 'Performance' })).toBeInTheDocument();
|
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' });
|
const subredditTable = screen.getByRole('table', { name: 'Subreddit performance' });
|
||||||
expect(within(subredditTable).getByText('n = 40')).toBeInTheDocument();
|
expect(within(subredditTable).getByText('n = 40')).toBeInTheDocument();
|
||||||
expect(within(subredditTable).getByText('n = 3')).toBeInTheDocument();
|
expect(within(subredditTable).getByText('n = 3')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ describe('Database Client', () => {
|
|||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||||
.all() as { name: string }[];
|
.all() as { name: string }[];
|
||||||
// Existing domain/config tables plus account, conversation, sync and media ownership stores.
|
// 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', () => {
|
it('tracks schema version and migrates a legacy sent_messages table transactionally', () => {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> }> = [];
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
+20
-3
@@ -17,6 +17,7 @@ function createMockRedditApi(posts: RedditPost[] = []): RedditApiClient {
|
|||||||
function createMockPostsRepo(processedIds: Set<string> = new Set()): PostsRepository {
|
function createMockPostsRepo(processedIds: Set<string> = new Set()): PostsRepository {
|
||||||
return {
|
return {
|
||||||
isProcessed: vi.fn((id: string) => processedIds.has(id)),
|
isProcessed: vi.fn((id: string) => processedIds.has(id)),
|
||||||
|
isProcessedAnywhere: vi.fn((id: string) => processedIds.has(id)),
|
||||||
insert: vi.fn(),
|
insert: vi.fn(),
|
||||||
updateAnalysis: vi.fn(),
|
updateAnalysis: vi.fn(),
|
||||||
markMessageSent: vi.fn(),
|
markMessageSent: vi.fn(),
|
||||||
@@ -86,6 +87,23 @@ describe('MonitorService', () => {
|
|||||||
expect(result.length).toBe(0);
|
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 () => {
|
it('skips posts from users already messaged', async () => {
|
||||||
const post1: RedditPost = {
|
const post1: RedditPost = {
|
||||||
id: 't3_1', subreddit: 'sub1', author: 'already_msgd', title: 'Post 1',
|
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)
|
// (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 = {
|
const post: RedditPost = {
|
||||||
id: 't3_1', subreddit: 'sub1', author: 'user1', title: 'Post',
|
id: 't3_1', subreddit: 'sub1', author: 'user1', title: 'Post',
|
||||||
url: 'http://example.com/1.jpg', permalink: '/r/sub1/1', thumbnail: '',
|
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);
|
monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0);
|
||||||
await monitor.getUnprocessedPosts();
|
await monitor.getUnprocessedPosts();
|
||||||
|
|
||||||
// Verify isProcessed was called
|
expect(postsRepo.isProcessedAnywhere).toHaveBeenCalledWith('t3_1');
|
||||||
expect(postsRepo.isProcessed).toHaveBeenCalledWith('t3_1');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('image/chat interleaving', () => {
|
describe('image/chat interleaving', () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import axios from 'axios';
|
|||||||
import type { AxiosInstance } from 'axios';
|
import type { AxiosInstance } from 'axios';
|
||||||
import type { BrowserContext } from 'playwright';
|
import type { BrowserContext } from 'playwright';
|
||||||
import { RedditApiClient, RedditRateLimitError } from '../src/reddit/api.js';
|
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 type { RedditPost } from '../src/reddit/api.js';
|
||||||
import { createMockLogger } from '../tests/mockLogger.js';
|
import { createMockLogger } from '../tests/mockLogger.js';
|
||||||
import { getTestConfig } from '../tests/testUtils.js';
|
import { getTestConfig } from '../tests/testUtils.js';
|
||||||
@@ -517,6 +518,23 @@ describe('RedditApiClient', () => {
|
|||||||
)).toBe(true);
|
)).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 () => {
|
it('parses valid image posts from response', async () => {
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
data: { data: { children: [
|
data: { data: { children: [
|
||||||
|
|||||||
@@ -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<void>((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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
MessagesRepository,
|
MessagesRepository,
|
||||||
SuccessfulMessagesRepository,
|
SuccessfulMessagesRepository,
|
||||||
ConversationStateRepository,
|
ConversationStateRepository,
|
||||||
|
ConversationOutcomesRepository,
|
||||||
|
RecipientHistoryRepository,
|
||||||
} from '../src/database/repositories.js';
|
} from '../src/database/repositories.js';
|
||||||
import { buildConversationState } from '../src/services/conversation-state.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', () => {
|
describe('Repository edge cases', () => {
|
||||||
it('handles special characters in post titles', () => {
|
it('handles special characters in post titles', () => {
|
||||||
const repos = createTestRepositories();
|
const repos = createTestRepositories();
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ describe('ResponseCheckerService', () => {
|
|||||||
expect(successfulMessagesRepo.insert).not.toHaveBeenCalled();
|
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 = {
|
const post: ProcessedPostRecord = {
|
||||||
id: 'post1',
|
id: 'post1',
|
||||||
subreddit: 'gonewild',
|
subreddit: 'gonewild',
|
||||||
@@ -245,13 +245,8 @@ describe('ResponseCheckerService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await checker.checkForResponses();
|
await checker.checkForResponses();
|
||||||
expect(successfulMessagesRepo.insert).toHaveBeenCalled();
|
expect(messagesRepo.markGotResponse).toHaveBeenCalledWith(1);
|
||||||
|
expect(successfulMessagesRepo.insert).not.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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not turn a declined reply into a learning example', async () => {
|
it('does not turn a declined reply into a learning example', async () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export function createTestDb(): Database.Database {
|
|||||||
// Disable FK for tests - we manage referential integrity manually
|
// Disable FK for tests - we manage referential integrity manually
|
||||||
db.pragma('foreign_keys = OFF');
|
db.pragma('foreign_keys = OFF');
|
||||||
runMigrations(db);
|
runMigrations(db);
|
||||||
|
db.pragma('foreign_keys = OFF');
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +78,7 @@ export function getTestConfig(overrides?: Partial<Config>): Config {
|
|||||||
strategies: {
|
strategies: {
|
||||||
explorationRate: 0.2,
|
explorationRate: 0.2,
|
||||||
minSamples: 5,
|
minSamples: 5,
|
||||||
|
responseWindowHours: 48,
|
||||||
variants: [
|
variants: [
|
||||||
{ id: 'variant-a', weight: 1, promptOverride: 'Be direct' },
|
{ id: 'variant-a', weight: 1, promptOverride: 'Be direct' },
|
||||||
{ id: 'variant-b', weight: 1, promptOverride: 'Be teasing' },
|
{ id: 'variant-b', weight: 1, promptOverride: 'Be teasing' },
|
||||||
@@ -86,6 +88,7 @@ export function getTestConfig(overrides?: Partial<Config>): Config {
|
|||||||
chatStrategies: {
|
chatStrategies: {
|
||||||
explorationRate: 0.2,
|
explorationRate: 0.2,
|
||||||
minSamples: 5,
|
minSamples: 5,
|
||||||
|
responseWindowHours: 48,
|
||||||
variants: [],
|
variants: [],
|
||||||
...overrides?.chatStrategies,
|
...overrides?.chatStrategies,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest';
|
|||||||
import type { BrowserClient } from '../src/browser/client.js';
|
import type { BrowserClient } from '../src/browser/client.js';
|
||||||
import { ActivityLogRepository } from '../src/database/repositories.js';
|
import { ActivityLogRepository } from '../src/database/repositories.js';
|
||||||
import { WebServer } from '../src/web/server.js';
|
import { WebServer } from '../src/web/server.js';
|
||||||
|
import { OverviewResponseSchema } from '../src/web/agent-api-contracts.js';
|
||||||
import { createMockLogger } from './mockLogger.js';
|
import { createMockLogger } from './mockLogger.js';
|
||||||
import { createTestDb, createTestRepositories, getTestConfig } from './testUtils.js';
|
import { createTestDb, createTestRepositories, getTestConfig } from './testUtils.js';
|
||||||
|
|
||||||
@@ -31,13 +32,25 @@ function createWorkspace(includeReactBuild = true): string {
|
|||||||
return workspace;
|
return workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startServer(options: { debugEnabled?: boolean; includeReactBuild?: boolean } = {}): Promise<RunningServer> {
|
async function startServer(options: {
|
||||||
|
debugEnabled?: boolean;
|
||||||
|
includeReactBuild?: boolean;
|
||||||
|
agentReadToken?: string;
|
||||||
|
agentControlToken?: string;
|
||||||
|
} = {}): Promise<RunningServer> {
|
||||||
const database = createTestDb();
|
const database = createTestDb();
|
||||||
const repositories = createTestRepositories(database);
|
const repositories = createTestRepositories(database);
|
||||||
const workspace = createWorkspace(options.includeReactBuild);
|
const workspace = createWorkspace(options.includeReactBuild);
|
||||||
const server = new WebServer(
|
const server = new WebServer(
|
||||||
0,
|
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.postsRepo,
|
||||||
repositories.messagesRepo,
|
repositories.messagesRepo,
|
||||||
repositories.successfulMessagesRepo,
|
repositories.successfulMessagesRepo,
|
||||||
@@ -110,4 +123,44 @@ describe('authenticated React dashboard cutover', () => {
|
|||||||
expect(upload.status).toBe(404);
|
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 });
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -48,6 +48,28 @@ describe('dashboard authentication', () => {
|
|||||||
expect(await response.json()).toEqual({ ok: true });
|
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 () => {
|
it('issues an HttpOnly session only for a schema-valid token payload', async () => {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ describe('control route realtime events', () => {
|
|||||||
const context = {
|
const context = {
|
||||||
config: { browser: { dryRun: true } },
|
config: { browser: { dryRun: true } },
|
||||||
services: { accountAuth: { isFullyAuthenticated: () => true } },
|
services: { accountAuth: { isFullyAuthenticated: () => true } },
|
||||||
|
getBotRunning: () => running,
|
||||||
setBotRunning: (next: boolean) => { running = next; },
|
setBotRunning: (next: boolean) => { running = next; },
|
||||||
activity: { push: vi.fn() },
|
activity: { push: vi.fn() },
|
||||||
logger: { info: vi.fn() },
|
logger: { info: vi.fn() },
|
||||||
|
|||||||
Reference in new issue
Block a user