From d0a8bd0e2fd492f0e7d616865feb67810dd5e62b Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Mon, 10 Aug 2026 17:31:05 +0100 Subject: [PATCH] feat: expand outreach targeting and messaging --- .../src/features/outreach/outreach-page.tsx | 49 ++++++ .../src/features/outreach/outreach.css | 25 ++++ .../src/features/settings/settings-model.ts | 28 +++- .../features/settings/settings-sections.tsx | 39 ++++- config.yaml | 4 + docs/api.md | 1 + docs/configuration.md | 1 + docs/data-flow.md | 6 +- docs/database.md | 2 +- docs/frontend.md | 4 + docs/product-and-safety.md | 4 +- docs/services.md | 2 + packages/api-contracts/src/config.ts | 8 + packages/api-contracts/src/outreach.ts | 23 +++ .../api-contracts/tests/contracts.test.ts | 7 +- src/ai/openrouter.ts | 18 ++- src/config/index.ts | 17 +++ src/mcp/server.ts | 9 ++ src/services/physique-targeting.ts | 30 ++++ src/services/processor.ts | 93 ++++++------ src/services/reddit-user-messenger.ts | 140 ++++++++++++++++++ src/web/agent-api-contracts.ts | 13 ++ src/web/api-catalog.ts | 3 + src/web/routes/agent.ts | 25 +++- src/web/routes/config.ts | 21 ++- src/web/routes/outreach.ts | 34 +++++ src/web/server.ts | 1 + src/web/types.ts | 5 + tests/api-catalog.test.ts | 6 + tests/candidate-queue.test.ts | 4 + tests/dashboard/app.test.tsx | 30 +++- tests/dashboard/settings.test.tsx | 47 +++++- tests/mcp-server.test.ts | 11 +- tests/openrouter-client.test.ts | 20 +++ tests/processor.test.ts | 94 +++++++++--- tests/reddit-account-runtime-builder.test.ts | 4 +- tests/reddit-user-messenger.test.ts | 75 ++++++++++ tests/testUtils.ts | 1 + tests/web/config-route.test.ts | 40 +++++ tests/web/outreach-route.test.ts | 28 +++- 40 files changed, 890 insertions(+), 82 deletions(-) create mode 100644 src/services/physique-targeting.ts create mode 100644 src/services/reddit-user-messenger.ts create mode 100644 tests/reddit-user-messenger.test.ts diff --git a/apps/dashboard/src/features/outreach/outreach-page.tsx b/apps/dashboard/src/features/outreach/outreach-page.tsx index 7389135..1f36c57 100644 --- a/apps/dashboard/src/features/outreach/outreach-page.tsx +++ b/apps/dashboard/src/features/outreach/outreach-page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import type { OutreachCandidate, OutreachCandidateStatus } from '../../../../../packages/api-contracts/src/index.js'; import { AccountOwnerBadge } from '../../components/account-owner-badge.js'; +import { OriginPostCard } from '../../components/origin-post.js'; import { RedditUsernameLink } from '../../components/reddit-username-link.js'; import { formatExactTime, formatShortRelativeTime } from '../conversations/relative-time.js'; import { outreachApi } from './outreach-api.js'; @@ -37,6 +38,52 @@ function formatWaitTime(timestamp: number, now: number): string { return `in ${Math.ceil(remaining / 86_400_000)}d`; } +function PreparedCandidateDetails({ candidate }: { candidate: OutreachCandidate }) { + const preparation = candidate.preparation; + if (!preparation) return null; + const targeting = preparation.physiqueTargeting; + const strategy = preparation.scripted + ? 'Scripted opener' + : preparation.strategyId ?? 'Default strategy'; + + return ( +
+
+ {preparation.sourceImageUrl ? ( + + ) : null} +
+
+
Attractiveness{preparation.attractivenessScore === null ? 'Not scored' : `${preparation.attractivenessScore}/100`}Used in the opener prompt
+
Physique{preparation.physiqueScore === null ? 'Not scorable' : `${preparation.physiqueScore}/10`}{targeting?.enabled ? `Target ${targeting.minScore}–${targeting.maxScore}` : 'Range filter off'}
+
Detected gender{preparation.detectedGender}Image analysis
+
+
+ Strategy {strategy} + {preparation.selectionReason ? Selection {preparation.selectionReason} : null} + Tone {preparation.toneId} + Model {preparation.modelId} +
+
+
+
+ Prepared message +
{preparation.message}
+
+
+ Show image analysis +
{preparation.imageAnalysis}
+
+
+ ); +} + function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now: number }) { const waitingUntil = candidate.availableAt > now; const isKnownStatus = KNOWN_STATUSES.has(candidate.status); @@ -61,11 +108,13 @@ function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now:
{candidate.hasPreparedMessage ? 'Message prepared' : 'Not prepared yet'} + {candidate.physiqueScore !== null ? Physique {candidate.physiqueScore}/10 : null} {candidate.attemptCount} {candidate.attemptCount === 1 ? 'attempt' : 'attempts'} {waitingUntil ? Retry {formatWaitTime(candidate.availableAt, now)} : null}
{candidate.note ?

Latest note{candidate.note}

: null} {candidate.owner ? : null} + ); diff --git a/apps/dashboard/src/features/outreach/outreach.css b/apps/dashboard/src/features/outreach/outreach.css index 988046a..dac953c 100644 --- a/apps/dashboard/src/features/outreach/outreach.css +++ b/apps/dashboard/src/features/outreach/outreach.css @@ -43,6 +43,31 @@ .candidate-note { display: grid; gap: .18rem; margin: 0; padding: .55rem .7rem; color: #d9c99e; background: #251f13; border-left: 2px solid var(--warning); border-radius: .35rem; font-size: .78rem; overflow-wrap: anywhere; } .candidate-note strong { color: var(--warning-text); font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; } .outreach-candidate .account-owner-badge { margin-top: -.1rem; } +.candidate-preparation { display: grid; gap: .8rem; padding-top: .8rem; border-top: 1px solid var(--border); } +.candidate-preparation-grid { display: grid; gap: .8rem; } +.candidate-preparation .origin-post-card { grid-template-columns: 1fr; align-items: stretch; gap: .55rem; margin: 0; padding: .6rem; background: #111915; border: 1px solid var(--border); border-radius: .6rem; } +.candidate-preparation .origin-post-image { width: 100%; height: 12rem; } +.candidate-preparation-copy { display: grid; gap: .7rem; min-width: 0; } +.candidate-score-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .55rem; } +.candidate-score-grid > div { display: grid; gap: .08rem; min-width: 0; padding: .6rem .7rem; background: #111c17; border: 1px solid var(--border); border-radius: .55rem; } +.candidate-score-grid span, .candidate-prepared-message > span { color: var(--muted); font-size: .66rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; } +.candidate-score-grid strong { color: #eef6f1; font-size: 1rem; text-transform: capitalize; overflow-wrap: anywhere; } +.candidate-score-grid small { color: var(--muted); font-size: .67rem; } +.candidate-preparation-metadata { display: flex; flex-wrap: wrap; gap: .4rem .8rem; color: var(--muted); font-size: .72rem; overflow-wrap: anywhere; } +.candidate-preparation-metadata strong { color: #dce8e2; } +.candidate-prepared-message { display: grid; gap: .35rem; } +.candidate-prepared-message blockquote { margin: 0; padding: .7rem .8rem; color: #eef6f1; background: rgba(117, 214, 157, .07); border-left: 3px solid var(--accent); border-radius: .35rem; white-space: pre-wrap; overflow-wrap: anywhere; } +.candidate-analysis { padding: .55rem .7rem; background: #111915; border: 1px solid var(--border); border-radius: .5rem; } +.candidate-analysis summary { width: fit-content; color: var(--accent); cursor: pointer; font-size: .75rem; font-weight: 750; } +.candidate-analysis pre { margin: .65rem 0 0; color: #dce8e2; font: inherit; font-size: .76rem; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; } + +@media (min-width: 48rem) { + .candidate-preparation-grid:has(.origin-post-card) { grid-template-columns: minmax(15rem, .8fr) minmax(0, 1.2fr); align-items: stretch; } +} + +@media (max-width: 34rem) { + .candidate-score-grid { grid-template-columns: 1fr; } +} @media (min-width: 64rem) { .outreach-stages { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; } diff --git a/apps/dashboard/src/features/settings/settings-model.ts b/apps/dashboard/src/features/settings/settings-model.ts index 37327f7..67136e1 100644 --- a/apps/dashboard/src/features/settings/settings-model.ts +++ b/apps/dashboard/src/features/settings/settings-model.ts @@ -29,6 +29,9 @@ export interface SettingsDraft { prioritizeRecentPosts: boolean; recentWindowMinutes: string; maxReadyCandidates: string; + physiqueTargetingEnabled: boolean; + physiqueMinScore: string; + physiqueMaxScore: string; subreddits: SubredditDraft[]; advancedConfigJson: string; } @@ -57,12 +60,22 @@ const ValidatedSettingsSchema = z.strictObject({ prioritizeRecentPosts: z.boolean(), recentWindowMinutes: z.number().finite().positive('Recent window must be greater than 0 minutes.'), maxReadyCandidates: z.number().int().min(1, 'Ready-post limit must be at least 1.').max(500, 'Ready-post limit cannot exceed 500.'), + physiqueTargetingEnabled: z.boolean(), + physiqueMinScore: z.number().int().min(1, 'Minimum physique score must be between 1 and 10.').max(10, 'Minimum physique score must be between 1 and 10.'), + physiqueMaxScore: z.number().int().min(1, 'Maximum physique score must be between 1 and 10.').max(10, 'Maximum physique score must be between 1 and 10.'), subreddits: z.array(EditableSubredditSchema).min(1, 'At least one subreddit is required.'), }).superRefine((value, context) => { const names = value.subreddits.map((subreddit) => subreddit.name.trim().toLowerCase()); if (new Set(names).size !== names.length) { context.addIssue({ code: 'custom', path: ['subreddits'], message: 'Subreddit names must be unique.' }); } + if (value.physiqueMinScore > value.physiqueMaxScore) { + context.addIssue({ + code: 'custom', + path: ['physiqueMinScore'], + message: 'Minimum physique score cannot exceed maximum physique score.', + }); + } }); let nextRowId = 0; @@ -81,6 +94,9 @@ export function createSettingsDraft(config: ConfigResponse): SettingsDraft { prioritizeRecentPosts: config.reddit.prioritizeRecentPosts, recentWindowMinutes: String(config.reddit.recentWindowMinutes), maxReadyCandidates: String(config.reddit.maxReadyCandidates), + physiqueTargetingEnabled: config.reddit.physiqueTargeting.enabled, + physiqueMinScore: String(config.reddit.physiqueTargeting.minScore), + physiqueMaxScore: String(config.reddit.physiqueTargeting.maxScore), subreddits: config.subreddits.map((subreddit) => ({ rowId: createSubredditRowId(), name: subreddit.name, @@ -104,6 +120,8 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR if (draft.jitterSeconds.trim() === '') requiredErrors.jitterSeconds = 'Jitter is required.'; if (draft.recentWindowMinutes.trim() === '') requiredErrors.recentWindowMinutes = 'Recent window is required.'; if (draft.maxReadyCandidates.trim() === '') requiredErrors.maxReadyCandidates = 'Ready-post limit is required.'; + if (draft.physiqueMinScore.trim() === '') requiredErrors.physiqueMinScore = 'Minimum physique score is required.'; + if (draft.physiqueMaxScore.trim() === '') requiredErrors.physiqueMaxScore = 'Maximum physique score is required.'; draft.subreddits.forEach((subreddit, index) => { if (subreddit.name.trim() === '') requiredErrors[`subreddits.${index}.name`] = 'Subreddit name is required.'; if (subreddit.weight.trim() !== '' && Number(subreddit.weight) <= 0) { @@ -140,6 +158,9 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR prioritizeRecentPosts: draft.prioritizeRecentPosts, recentWindowMinutes: Number(draft.recentWindowMinutes), maxReadyCandidates: Number(draft.maxReadyCandidates), + physiqueTargetingEnabled: draft.physiqueTargetingEnabled, + physiqueMinScore: Number(draft.physiqueMinScore), + physiqueMaxScore: Number(draft.physiqueMaxScore), subreddits: draft.subreddits.map((subreddit) => ({ name: subreddit.name.trim(), enabled: subreddit.enabled, @@ -185,6 +206,11 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR prioritizeRecentPosts: parsed.data.prioritizeRecentPosts, recentWindowMinutes: parsed.data.recentWindowMinutes, maxReadyCandidates: parsed.data.maxReadyCandidates, + physiqueTargeting: { + enabled: parsed.data.physiqueTargetingEnabled, + minScore: parsed.data.physiqueMinScore, + maxScore: parsed.data.physiqueMaxScore, + }, }, subreddits: parsed.data.subreddits, ...(advanced ? { advanced } : {}), @@ -217,7 +243,7 @@ export function isDraftDirty(draft: SettingsDraft, persisted: SettingsDraft): bo */ export function changedDraftFields(previous: SettingsDraft, next: SettingsDraft): string[] { const changed: string[] = []; - const topLevelKeys = ['minDelayMinutes', 'maxMessagesPerHour', 'jitterSeconds', 'dryRun', 'prioritizeRecentPosts', 'recentWindowMinutes', 'maxReadyCandidates', 'advancedConfigJson'] as const; + const topLevelKeys = ['minDelayMinutes', 'maxMessagesPerHour', 'jitterSeconds', 'dryRun', 'prioritizeRecentPosts', 'recentWindowMinutes', 'maxReadyCandidates', 'physiqueTargetingEnabled', 'physiqueMinScore', 'physiqueMaxScore', 'advancedConfigJson'] as const; for (const key of topLevelKeys) { if (previous[key] !== next[key]) changed.push(key); } diff --git a/apps/dashboard/src/features/settings/settings-sections.tsx b/apps/dashboard/src/features/settings/settings-sections.tsx index 917d39d..a8fadd5 100644 --- a/apps/dashboard/src/features/settings/settings-sections.tsx +++ b/apps/dashboard/src/features/settings/settings-sections.tsx @@ -12,11 +12,12 @@ interface NumberFieldProps { label: string; name: string; min?: number; + max?: number; value: string; onChange(value: string): void; } -function NumberField({ error, label, name, min, value, onChange }: NumberFieldProps) { +function NumberField({ error, label, name, min, max, value, onChange }: NumberFieldProps) { const errorId = `${name}-error`; return (