feat: expand outreach targeting and messaging

This commit is contained in:
jjbrunton committed 2026-08-10 17:31:05 +01:00
1 parent 7defff422d
commit d0a8bd0e2f
40 files changed
+890 -82

No files matched your search

@@ -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 (
<section aria-label={`Prepared outreach for u/${candidate.author}`} className="candidate-preparation">
<div className="candidate-preparation-grid">
{preparation.sourceImageUrl ? (
<OriginPostCard post={{
title: candidate.title,
url: candidate.postUrl,
imageUrl: preparation.sourceImageUrl,
subreddit: candidate.subreddit,
postedAt: candidate.postCreatedAt,
}} />
) : null}
<div className="candidate-preparation-copy">
<div className="candidate-score-grid" aria-label="Preparation scoring">
<div><span>Attractiveness</span><strong>{preparation.attractivenessScore === null ? 'Not scored' : `${preparation.attractivenessScore}/100`}</strong><small>Used in the opener prompt</small></div>
<div><span>Physique</span><strong>{preparation.physiqueScore === null ? 'Not scorable' : `${preparation.physiqueScore}/10`}</strong><small>{targeting?.enabled ? `Target ${targeting.minScore}–${targeting.maxScore}` : 'Range filter off'}</small></div>
<div><span>Detected gender</span><strong>{preparation.detectedGender}</strong><small>Image analysis</small></div>
</div>
<div className="candidate-preparation-metadata">
<span><strong>Strategy</strong> {strategy}</span>
{preparation.selectionReason ? <span><strong>Selection</strong> {preparation.selectionReason}</span> : null}
<span><strong>Tone</strong> {preparation.toneId}</span>
<span><strong>Model</strong> {preparation.modelId}</span>
</div>
</div>
</div>
<div className="candidate-prepared-message">
<span>Prepared message</span>
<blockquote>{preparation.message}</blockquote>
</div>
<details className="candidate-analysis">
<summary>Show image analysis</summary>
<pre>{preparation.imageAnalysis}</pre>
</details>
</section>
);
}
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:
<div className="candidate-meta">
<RedditUsernameLink username={candidate.author} />
<span>{candidate.hasPreparedMessage ? 'Message prepared' : 'Not prepared yet'}</span>
{candidate.physiqueScore !== null ? <span>Physique {candidate.physiqueScore}/10</span> : null}
<span>{candidate.attemptCount} {candidate.attemptCount === 1 ? 'attempt' : 'attempts'}</span>
{waitingUntil ? <span title={formatExactTime(candidate.availableAt)}>Retry {formatWaitTime(candidate.availableAt, now)}</span> : null}
</div>
{candidate.note ? <p className="candidate-note"><strong>Latest note</strong>{candidate.note}</p> : null}
{candidate.owner ? <AccountOwnerBadge owner={candidate.owner} /> : null}
<PreparedCandidateDetails candidate={candidate} />
</article>
</li>
);
@@ -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; }
@@ -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);
}
@@ -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 (
<label className="settings-field">
@@ -26,6 +27,7 @@ function NumberField({ error, label, name, min, value, onChange }: NumberFieldPr
aria-invalid={error ? 'true' : undefined}
aria-label={label}
min={min}
max={max}
name={name}
onChange={(event) => onChange(event.target.value)}
step="1"
@@ -66,6 +68,41 @@ export function MonitoringSection({ draft, errors, onChange }: SectionProps) {
onChange={(maxReadyCandidates) => onChange({ ...draft, maxReadyCandidates })}
value={draft.maxReadyCandidates}
/>
<div className="advanced-settings">
<div>
<h3>Physique targeting</h3>
<p>Apply a subjective 1–10 visible-physique range to image posts. Images without enough visible body evidence are screened out while this is enabled.</p>
</div>
<label className="toggle-field">
<input
aria-label="Enable physique targeting"
checked={draft.physiqueTargetingEnabled}
onChange={(event) => onChange({ ...draft, physiqueTargetingEnabled: event.target.checked })}
type="checkbox"
/>
<span><strong>Enable physique targeting</strong><small>Chat posts are unaffected.</small></span>
</label>
<div className="settings-grid">
<NumberField
error={errors.physiqueMinScore}
label="Minimum physique score"
min={1}
max={10}
name="minimum-physique-score"
onChange={(physiqueMinScore) => onChange({ ...draft, physiqueMinScore })}
value={draft.physiqueMinScore}
/>
<NumberField
error={errors.physiqueMaxScore}
label="Maximum physique score"
min={1}
max={10}
name="maximum-physique-score"
onChange={(physiqueMaxScore) => onChange({ ...draft, physiqueMaxScore })}
value={draft.physiqueMaxScore}
/>
</div>
</div>
</section>
);
}
+4
View File
@@ -49,6 +49,10 @@ reddit:
prioritizeRecentPosts: true # Prefer posts within recentWindowMinutes; older posts only as fallback
recentWindowMinutes: 30
maxReadyCandidates: 25 # Pause screening when this many posts are ready to send
physiqueTargeting:
enabled: false # When enabled, only scored image posts inside this range continue
minScore: 1 # 1 = visibly very large-bodied physique
maxScore: 10 # 10 = exceptionally lean/toned, model-like physique
# sellerKeywords: [...] # Uncomment to override default keyword list
conversations:
+1
View File
@@ -59,6 +59,7 @@ an administrator credential.
| `GET` | `/api/v1/jobs` | List recent asynchronous jobs | read | no |
| `GET` | `/api/v1/jobs/{id}` | Get asynchronous job state | read | no |
| `POST` | `/api/v1/jobs` | Start chat or media synchronisation | control | yes |
| `POST` | `/api/v1/messages` | Send a message to a Reddit user with automatic account selection | admin | yes |
| `GET` | `/api/stats` | Get bot statistics | read | no |
| `GET` | `/api/analytics/candidate-funnel` | Get durable candidate funnel counts | read | no |
| `GET` | `/api/activity` | Get recent activity | read | no |
+1
View File
@@ -37,6 +37,7 @@ Never document real values. `.env` is ignored and may contain production credent
- `filterSellers`, `sellerKeywords`, `sellerDomains`: seller policy inputs; see also the `conversations` section, whose off-platform kill is independent of this flag. The defaults treat Telegram references, including `t.me` and `telegram.me` links, as seller signals alongside paid-content platforms and payment prompts.
- `prioritizeRecentPosts`, `recentWindowMinutes`: recency/fallback behavior.
- `maxReadyCandidates`: hard ceiling for the active candidate target; valid range 1–500. Runtime lowers the actual target to what authenticated running accounts can send within `recentWindowMinutes` under current rolling-hour and message-spacing limits.
- `physiqueTargeting`: optional image-candidate filter with `enabled`, inclusive `minScore`, and inclusive `maxScore` values from 1 to 10. It is disabled by default. When enabled, a model score outside the range or an image without enough visible body evidence to score is rejected before opener generation. The setting is hot-read by existing account runtimes; chat sources are unaffected.
Subreddit `type` is `image` or `chat`. Conversation policy contains `mode`, `maxAutomatedTurns`, and `rapportTurnsBeforeMediaAsk`; hybrid rapport turns must be lower than the maximum.
+4 -2
View File
@@ -36,9 +36,9 @@ A screening worker leases one discovered candidate. `MonitorService.screenCandid
3. Seller evidence from the post, profile/about, and recent activity.
4. Source-specific preparation in `ProcessorService`.
For image posts, preparation downloads and validates the image, detects target gender, performs model analysis, selects an image strategy, and generates an opener. For chat posts, it selects a chat strategy and generates from the post text. Both paths apply outbound safety checks and return either a prepared immutable result or an explicit skip reason.
For image posts, preparation downloads and validates the image, detects target gender, and performs model analysis. The analysis includes a separate 1–10 visible-physique score only when enough of the body is visible. If physique targeting is enabled, an unknown score or a score outside the configured inclusive range becomes an explicit rejection before strategy selection and opener generation. Passing image posts then select an image strategy and generate an opener. Chat posts do not use physique targeting; they select a chat strategy and generate from the post text. Both paths apply outbound safety checks and return either a prepared immutable result or an explicit skip reason.
Terminal results become rejected candidates. Rate limits and transient failures become retryable candidates. Prepared candidates become ready; the persisted snapshot means a different account can deliver without repeating AI work.
Terminal results become rejected candidates. Rate limits and transient failures become retryable candidates. Prepared candidates become ready; the persisted snapshot includes the opener, grounded image analysis, attractiveness and physique scores, the physique range used, source-image URL, and experiment/model/tone metadata. This lets a different account deliver without repeating AI work and lets the authenticated operator inspect the exact preparation context.
### 3. Dispatch
@@ -62,6 +62,8 @@ Do not add local retries around this layer without preserving the shared schedul
## Reply-first conversation automation
Explicit bot clients can send to a username through `POST /api/v1/messages` or the MCP `send_message_to_user` tool without selecting an account. The selector compares cached conversations case-insensitively and reuses the account that most recently interacted with that recipient. If no conversation exists, it chooses an authenticated running account with no invitation cooldown, preferring the lowest hourly send count. The selected account's normal rate limiter still owns the send. An API reply to an existing conversation marks automation as user-overridden so autonomous replies do not race the external caller.
Each account runtime refreshes recent chats and processes actionable inbound conversations oldest-first before it can accept new outreach. After the initial conservative startup scan, the persisted latest Matrix message event plus the worker's prior-scan watermark is authoritative: a newly observed inbound event newer than the confirmed opener remains actionable even if Matrix's presentation-only unread notification count has already returned to zero. On startup, read-marked rooms without a pending reply claim are not revived. Durable event claims, conversation policy state, and a later outbound event prevent duplicate replies.
Outgoing Matrix events are attributed against the recorded opener, durable automated-reply fingerprints, and confirmed exchange-library event records. If synchronization observes an unmatched outgoing message, the conversation becomes operator-owned before another reply can be generated. An unmatched outgoing message also supersedes, rather than confirms, a pending automated claim; only the claimed reply text can reconcile that claim as sent.
+1 -1
View File
@@ -32,7 +32,7 @@ The special legacy-unassigned account exists only to preserve rows created befor
### Durable work and idempotency
- `candidate_queue`: global candidate snapshot, source/priority, state, prepared payload, worker lease, retry timing, selected account, and rejection/error details.
- `candidate_queue`: global candidate snapshot, source/priority, state, prepared payload, worker lease, retry timing, selected account, and rejection/error details. Image preparation snapshots retain the generated opener, grounded analysis, both AI scores, the physique range used, source-image URL, and experiment/model/tone metadata for delivery and operator review.
- `delivery_outbox`: account-scoped prepared delivery and idempotency status retained for legacy/recovery paths.
- `conversation_states`: account/conversation policy and derived state JSON.
- `conversation_automation_claims`: account/conversation/inbound-event claim with intended reply and `pending`, `sent`, or terminal `superseded` state plus audit timestamps.
+4
View File
@@ -23,6 +23,10 @@ Shared HTTP/SSE schemas live in `packages/api-contracts`. The server and client
Navigation is declared in `app/navigation.ts`; route elements are composed in `app/router.tsx`.
The Monitoring settings section owns the optional physique-targeting toggle and inclusive 1–10 range. It explains that the signal is subjective, applies only to image posts, and screens out unscorable images while enabled.
Prepared cards in the authenticated outreach funnel expose the exact opener, grounded image analysis, attractiveness and physique scores, target range, detected gender, strategy/tone/model metadata, and original source image. The source image uses the shared `OriginPostCard` privacy behavior: blurred initially, first click reveals it, and a later click opens the full-screen preview. Candidates from older persisted snapshots degrade cleanly when newer optional metadata is absent.
## State model
- Remote state: TanStack Query snapshots parsed by shared Zod schemas.
+3 -1
View File
@@ -21,7 +21,7 @@ The product has five connected goals:
- Recipient deduplication is case-insensitive, durable across restarts, and protected by a live reservation during sends.
- Remote side effects are fail-closed: a send is recorded only after the chat service confirms it; interrupted reply claims reconcile against Matrix history before retrying. A queued reply refreshes Matrix immediately before sending and becomes an auditable superseded claim if a newer inbound event or terminal state appeared while it waited.
- Dry-run prepares and records decisions but never sends.
- Operators can see funnel stages, account state, rate limits, failures, handoffs, and recent activity rather than inferring success from a process-health check.
- Operators can see funnel stages, prepared opener and scoring context, blurred source images, account state, rate limits, failures, handoffs, and recent activity rather than inferring success from a process-health check.
## Candidate policy
@@ -30,6 +30,8 @@ Configured subreddits have a source type:
- `image`: image-led posts are downloaded and analyzed before an opener is generated.
- `chat`: text-led posts require a parseable self-declared gender and adult age, then use the chat-opener prompt.
Image analysis may produce a subjective 1–10 visible-physique score. It is not a medical assessment and remains unknown when the image does not show enough of the body for a grounded judgment. Physique targeting is disabled by default. When an operator enables an inclusive minimum/maximum range, image candidates outside that range—and unscorable image candidates—are rejected before opener generation; chat candidates are unaffected.
Seller filtering combines the candidate post with Reddit profile/about and recent activity evidence. The default policy treats Telegram names and links as seller signals alongside paid-content platforms and payment prompts. The configured keyword/domain lists are policy inputs, not a substitute for the surrounding screening flow. A profile lookup failure is not itself proof that a person is a seller; network and rate-limit failures must remain distinguishable from a rejection.
The system must not manufacture personal facts. Generated messages must not claim shared location or proximity, invite an in-person meeting, invent identity/history, or ignore a conversation boundary. The final outbound safety check is intentionally separate from prompt wording.
+2
View File
@@ -9,6 +9,7 @@ This is an ownership map, not an API dump. Read the owning implementation and te
| `src/bootstrap.ts` | Dependency composition and startup wiring | Keep business policy in services; preserve initialization order and test seams. |
| `src/main-loop.ts` | Shared discovery/screening/dispatch workers and legacy loop | Loops are abortable; queue transitions and shutdown must remain recoverable. |
| `services/reddit-account-runtime.ts` | Immutable account runtime/factory/manager lifecycle | Same-account operations serialize; session refresh replaces one runtime. |
| `services/reddit-user-messenger.ts` | Account selection for explicit API/MCP messages | Prefer the newest existing conversation owner; otherwise choose the authenticated running account with the fewest hourly sends. |
| `services/reddit-account-runtime-builder.ts` | Concrete account dependency graph and account loop | Account-specific browser, Matrix, repos, limiter, automation, chat, and media live here. |
| `services/reddit-accounts.ts` | Account import/activation orchestration | Verification must bind Reddit cookies and Matrix identity before activation. |
| `services/account-auth-context.ts` | Current verified identity snapshot | Account work pauses unless both sides authenticate. |
@@ -22,6 +23,7 @@ This is an ownership map, not an API dump. Read the owning implementation and te
| `reddit/api.ts` | Reddit JSON calls, post parsing, seller/profile evidence, username resolution, error types | All reads must use the coordinator; redact error metadata. |
| `reddit/request-coordinator.ts` | Process-wide Reddit request spacing and cooldown | Space failed requests too; 429 cooldown is global. |
| `services/processor.ts` | Source-specific AI preparation and outbound safety | Returns prepared or explicit skip; performs no remote chat send. |
| `services/physique-targeting.ts` | Pure optional score-range decision for image candidates | Disabled means pass; enabled ranges reject unknown and out-of-range scores before opener generation. |
| `services/strategy-selector.ts` | Weighted explore/exploit selection and experiment metadata | Separate image/chat strategy sections; respect mature response window and sample floor. |
| `services/recipient-deduplicator.ts` | Global recipient history plus live reservation | Case-insensitive; release failures, commit successes, recover stale claims. |
| `utils/rate-limiter.ts` | Per-account send spacing/hour limits/jitter | Increment only for confirmed sends; reply priority may use the same limiter. |
+8
View File
@@ -22,6 +22,14 @@ export const RuntimeRedditConfigSchema = z.strictObject({
prioritizeRecentPosts: z.boolean(),
recentWindowMinutes: z.number(),
maxReadyCandidates: z.number().int().min(1).max(500),
physiqueTargeting: z.strictObject({
enabled: z.boolean(),
minScore: z.number().int().min(1).max(10),
maxScore: z.number().int().min(1).max(10),
}).refine((targeting) => targeting.minScore <= targeting.maxScore, {
message: 'Minimum physique score cannot exceed maximum physique score.',
path: ['minScore'],
}).default({ enabled: false, minScore: 1, maxScore: 10 }),
});
export const RuntimeConfigSchema = z.strictObject({
rateLimit: RateLimitConfigSchema,
+23
View File
@@ -17,6 +17,27 @@ export const OutreachStageCountsSchema = z.strictObject({
retryable: z.number().int().nonnegative(),
});
export const OutreachPreparationSchema = z.strictObject({
message: z.string(),
imageAnalysis: z.string(),
detectedGender: z.enum(['male', 'female', 'other', 'unknown']),
attractivenessScore: z.number().int().min(1).max(100).nullable(),
physiqueScore: z.number().int().min(1).max(10).nullable(),
physiqueTargeting: z.strictObject({
enabled: z.boolean(),
minScore: z.number().int().min(1).max(10),
maxScore: z.number().int().min(1).max(10),
}).nullable(),
sourceImageUrl: z.string().nullable(),
strategyId: z.string().nullable(),
experimentId: z.string().nullable(),
promptVersion: z.string().nullable(),
selectionReason: z.enum(['explore', 'exploit']).nullable(),
modelId: z.string(),
toneId: z.string(),
scripted: z.boolean(),
});
export const OutreachCandidateSchema = z.strictObject({
postId: z.string(),
subreddit: z.string(),
@@ -32,6 +53,8 @@ export const OutreachCandidateSchema = z.strictObject({
availableAt: z.number(),
attemptCount: z.number().int().nonnegative(),
hasPreparedMessage: z.boolean(),
physiqueScore: z.number().int().min(1).max(10).nullable().default(null),
preparation: OutreachPreparationSchema.nullable().default(null),
note: z.string().nullable(),
owner: RedditAccountOwnerSchema.nullable(),
});
@@ -149,7 +149,12 @@ describe('dashboard endpoint contracts', () => {
const payload = {
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 },
browser: { dryRun: false },
reddit: { prioritizeRecentPosts: true, recentWindowMinutes: 60, maxReadyCandidates: 25 },
reddit: {
prioritizeRecentPosts: true,
recentWindowMinutes: 60,
maxReadyCandidates: 25,
physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 },
},
subreddits: [{ name: 'example', enabled: true, type: 'chat', weight: 2, tone: 'direct' }],
tones: ['default', 'direct'],
};
+16 -2
View File
@@ -30,6 +30,8 @@ export interface ImageAnalysisResult {
description: string;
detectedGender: DetectedGender;
attractivenessScore: number | null;
/** Subjective 1-10 visible-physique score, or null when the image is insufficient. */
physiqueScore: number | null;
}
interface ProtectedTextPrompt {
@@ -370,6 +372,8 @@ Use every applicable tag; tags may overlap. Set safety to "nsfw" when nudity, ex
const startedAt = Date.now();
const genderPrompt = `First, identify the gender of the person in this image. Respond with EXACTLY one of these on the first line: GENDER:male, GENDER:female, GENDER:other, or GENDER:unknown
On the second line, assess only the directly visible physique. Output exactly PHYSIQUE_SCORE:<number> using an integer from 1 to 10, or PHYSIQUE_SCORE:UNKNOWN when the torso and enough of the body are not visible for a grounded assessment. Do not infer physique from a face, a close crop, clothing that obscures body shape, or off-frame anatomy. This is not a medical diagnosis: 1 means a visibly very large-bodied physique, 5 means a broadly average physique, and 10 means an exceptionally lean, toned, model-like physique. Ignore facial attractiveness, photography quality, pose, and setting.
Then provide your analysis:
${this.config.openrouter.imageAnalysisPrompt}`;
@@ -411,6 +415,12 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
detectedGender = genderMatch[1].toLowerCase() as DetectedGender;
}
let physiqueScore: number | null = null;
const physiqueMatch = fullResponse.match(/PHYSIQUE_SCORE[:\s*_]*[*_\s]*(UNKNOWN|10|[1-9])\b/i);
if (physiqueMatch && physiqueMatch[1].toUpperCase() !== 'UNKNOWN') {
physiqueScore = Number(physiqueMatch[1]);
}
// Parse attractiveness rating (1-100 scale) from response.
// Tolerate markdown wrapping like **RATING:** 63 or RATING: **63**.
let attractivenessScore: number | null = null;
@@ -425,6 +435,7 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
// Remove gender and rating lines from description
const description = fullResponse
.replace(/GENDER:\s*(male|female|other|unknown)\n?/i, '')
.replace(/\**\s*PHYSIQUE_SCORE[:\s*_]*[*_\s]*(?:UNKNOWN|10|[1-9])\**\n?/i, '')
.replace(/\**\s*RATING[:\s*]*[*_\s]*\d{1,3}\**\n?/i, '')
.trim();
@@ -436,10 +447,13 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
this.recordUsage('analyzeImage', model, startedAt, response, description ? 'success' : 'empty');
this.logger.debug(`Image analysis complete (${model}) - Gender: ${detectedGender}, Rating: ${attractivenessScore ?? 'n/a'}`);
this.logger.debug(
`Image analysis complete (${model}) - Gender: ${detectedGender}, `
+ `Physique: ${physiqueScore ?? 'n/a'}, Rating: ${attractivenessScore ?? 'n/a'}`,
);
this.logger.debug(`Description: ${description}`);
return { description, detectedGender, attractivenessScore };
return { description, detectedGender, attractivenessScore, physiqueScore };
}
private isRefusal(text: string): boolean {
+17
View File
@@ -37,6 +37,22 @@ export const SubredditConfigSchema = z.object({
export type SubredditConfig = z.infer<typeof SubredditConfigSchema>;
export const PhysiqueTargetingSchema = z.object({
enabled: z.boolean().default(false),
minScore: z.number().int().min(1).max(10).default(1),
maxScore: z.number().int().min(1).max(10).default(10),
}).superRefine((targeting, context) => {
if (targeting.minScore > targeting.maxScore) {
context.addIssue({
code: 'custom',
path: ['minScore'],
message: 'Minimum physique score cannot exceed maximum physique score.',
});
}
}).default({ enabled: false, minScore: 1, maxScore: 10 });
export type PhysiqueTargeting = z.infer<typeof PhysiqueTargetingSchema>;
const SubredditEntrySchema = z
.union([z.string().transform((s) => ({ name: s })), z.object({}).passthrough()])
.pipe(SubredditConfigSchema);
@@ -51,6 +67,7 @@ export const ConfigSchema = z.object({
prioritizeRecentPosts: z.boolean().default(false),
recentWindowMinutes: z.number().positive().default(60),
maxReadyCandidates: z.number().int().min(1).max(500).default(25),
physiqueTargeting: PhysiqueTargetingSchema,
sellerKeywords: z
.array(z.string())
.default([
+9
View File
@@ -134,6 +134,15 @@ export function createRedditBotMcpServer(client = new RedditBotApiClient()): Mcp
{ message },
)));
server.registerTool('send_message_to_user', {
description: 'Send a Reddit message with automatic account selection. An account with an existing conversation with the user is preferred.',
inputSchema: {
username: z.string().min(1).max(64),
message: z.string().min(1).max(10_000),
},
annotations: sendAnnotations,
}, async (input) => result(await client.post('/api/v1/messages', input)));
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: {
+30
View File
@@ -0,0 +1,30 @@
import type { PhysiqueTargeting } from '../config/index.js';
export type PhysiqueTargetingDecision =
| { accepted: true }
| { accepted: false; reason: string };
/**
* Applies the operator's optional physique range to an image analysis result.
* An enabled range is fail-closed: an image that cannot be scored cannot be
* claimed to fall inside the configured range.
*/
export function evaluatePhysiqueTargeting(
score: number | null,
targeting: PhysiqueTargeting,
): PhysiqueTargetingDecision {
if (!targeting.enabled) return { accepted: true };
if (score === null) {
return {
accepted: false,
reason: 'physique score unavailable while physique targeting is enabled',
};
}
if (score < targeting.minScore || score > targeting.maxScore) {
return {
accepted: false,
reason: `physique score ${score} outside target range ${targeting.minScore}-${targeting.maxScore}`,
};
}
return { accepted: true };
}
+51 -42
View File
@@ -1,16 +1,21 @@
import type { RedditApiClient, RedditPost } from '../reddit/api.js';
import type { OpenRouterClient, DetectedGender } from '../ai/openrouter.js';
import type { Logger } from '../utils/logger.js';
import type { Config } from '../config/index.js';
import type { Config, PhysiqueTargeting } from '../config/index.js';
import type { StrategySelector, SelectedStrategy } from './strategy-selector.js';
import { downloadImage, bufferToBase64, isValidImageMimeType, type DownloadedImage } from '../utils/image.js';
import { parseGenderTag } from '../utils/gender-tag.js';
import { resolveTone, type ToneProfile, type ToneKey } from '../config/tone-profiles.js';
import { evaluatePhysiqueTargeting } from './physique-targeting.js';
export interface ProcessedPost {
post: RedditPost;
imageAnalysis: string;
detectedGender: DetectedGender;
attractivenessScore: number | null;
physiqueScore: number | null;
physiqueTargeting: PhysiqueTargeting | null;
sourceImageUrl: string | null;
generatedMessage: string;
strategyId: string | null;
experimentId: string | null;
@@ -150,7 +155,28 @@ export class ProcessorService {
this.logger.debug(`Gender check passed: ${detectedGender}`);
}
// Select a strategy for A/B testing
// Analyze before strategy selection and opener generation so a rejected
// physique score costs only the analysis request.
let analysis;
try {
analysis = await this.aiClient.analyzeImage(base64, imageData.mimeType);
} catch (error) {
if (error instanceof Error && error.message.startsWith('AI_REFUSAL')) {
this.logger.warn(`AI refused to analyze post ${post.id} by u/${post.author} - skipping`);
return { skipReason: 'AI refused to analyze image' };
}
throw error;
}
const physiqueDecision = evaluatePhysiqueTargeting(
analysis.physiqueScore,
this.config.reddit.physiqueTargeting,
);
if (!physiqueDecision.accepted) {
this.logger.info(`Skipping post ${post.id} - ${physiqueDecision.reason}`);
return { skipReason: physiqueDecision.reason };
}
let selectedStrategy: SelectedStrategy | null = null;
if (this.strategySelector) {
selectedStrategy = this.strategySelector.selectStrategy();
@@ -159,7 +185,6 @@ export class ProcessorService {
}
}
// Resolve subreddit-specific tone profile
const tone = this.resolveTone(post.subreddit);
if (tone.scripted) {
this.logger.info(`Using scripted message for r/${post.subreddit}`);
@@ -167,47 +192,23 @@ export class ProcessorService {
this.logger.info(`Using tone "${tone.profile.label}" for r/${post.subreddit}`);
}
// Full analysis and message generation (only for matching posts)
let analysis;
let message;
try {
if (tone.scripted) {
// Replace substitution tokens in the scripted prompt with post-specific data
let scriptedPrompt = tone.scripted
const strategyPrompt = tone.scripted
? tone.scripted
.replace('{title}', post.title)
.replace('{author}', post.author)
.replace('{flair}', post.link_flair_text || '(none)');
({ analysis, message } = await this.aiClient.analyzeAndGenerateMessage(
base64,
imageData.mimeType,
post.title,
post.author,
this.successfulExamples,
scriptedPrompt,
post.link_flair_text ?? undefined,
undefined,
this.recentMessageProvider('image'),
));
} else {
({ analysis, message } = await this.aiClient.analyzeAndGenerateMessage(
base64,
imageData.mimeType,
post.title,
post.author,
this.successfulExamples,
selectedStrategy?.promptOverride,
post.link_flair_text ?? undefined,
tone.profile.promptGuidance || undefined,
this.recentMessageProvider('image'),
));
}
} catch (error) {
if (error instanceof Error && error.message.startsWith('AI_REFUSAL')) {
this.logger.warn(`AI refused to analyze post ${post.id} by u/${post.author} - skipping`);
return { skipReason: 'AI refused to analyze image' };
}
throw error;
}
.replace('{flair}', post.link_flair_text || '(none)')
: selectedStrategy?.promptOverride;
const message = await this.aiClient.generateMessage(
analysis.description,
post.title,
post.author,
this.successfulExamples,
strategyPrompt,
analysis.attractivenessScore,
post.link_flair_text ?? undefined,
tone.scripted ? undefined : tone.profile.promptGuidance || undefined,
this.recentMessageProvider('image'),
);
if (!message || message.length < 10) {
this.logger.warn(
@@ -220,6 +221,10 @@ export class ProcessorService {
post,
imageAnalysis: analysis.description,
detectedGender: analysis.detectedGender,
attractivenessScore: analysis.attractivenessScore,
physiqueScore: analysis.physiqueScore,
physiqueTargeting: { ...this.config.reddit.physiqueTargeting },
sourceImageUrl: imageUrl,
generatedMessage: message,
strategyId: selectedStrategy?.id || null,
experimentId: selectedStrategy?.experimentId ?? null,
@@ -304,6 +309,10 @@ export class ProcessorService {
post,
imageAnalysis: `[chat] ${post.title}${post.selftext ? ` — ${post.selftext.slice(0, 200)}` : ''}`,
detectedGender: detectedGender ?? 'unknown',
attractivenessScore: null,
physiqueScore: null,
physiqueTargeting: null,
sourceImageUrl: null,
generatedMessage: message,
strategyId: selectedStrategy?.id || null,
experimentId: selectedStrategy?.experimentId ?? null,
+140
View File
@@ -0,0 +1,140 @@
import type { RedditAccountRuntime, RedditAccountRuntimeManager } from './reddit-account-runtime.js';
import type { ActiveChat } from './chat/types.js';
import { buildConversationState, mergeConversationState } from './conversation-state.js';
export type RedditUserMessageResult = {
accountId: string;
redditUsername: string;
recipient: string;
conversationId: string;
reusedConversation: boolean;
};
type RuntimeConversation = { runtime: RedditAccountRuntime; conversation: ActiveChat };
/** Selects an account and performs one explicit, rate-limited operator/API send. */
export class RedditUserMessenger {
constructor(private readonly runtimes: RedditAccountRuntimeManager) {}
async send(username: string, message: string): Promise<RedditUserMessageResult> {
const recipient = username.trim().replace(/^u\//i, '');
const running = this.runningRuntimes();
if (running.length === 0) throw new RedditUserMessageError(503, 'No Reddit account is running');
const existing = await this.findExistingConversation(running, recipient);
if (existing) return this.sendExisting(existing, recipient, message);
const runtime = running
.filter((item) => item.resources.accountAuth.isFullyAuthenticated())
.filter((item) => !item.resources.chatService.getInvitationCooldown())
.sort(compareAvailableRuntimes)[0];
if (!runtime) throw new RedditUserMessageError(503, 'No authenticated Reddit account is available to start a conversation');
const started = await runtime.resources.rateLimiter.throttle(
() => runtime.resources.chatService.startConversationDetailed(recipient, message),
(result) => result.status === 'sent',
);
if (started.status !== 'sent') {
throw new RedditUserMessageError(started.status === 'terminal' ? 422 : 503, started.reason);
}
const conversationId = started.chat.roomId;
if (!conversationId) throw new RedditUserMessageError(502, 'Reddit confirmed the message without returning a conversation ID');
this.recordSend(runtime, recipient, `Started chat with u/${recipient}`);
runtime.resources.conversationStateRepo.save(
conversationId,
buildConversationState(started.chat.messages ?? [{ sender: 'Me', text: message, isSelf: true }]),
);
return resultFor(runtime, recipient, conversationId, false);
}
private runningRuntimes(): RedditAccountRuntime[] {
return this.runtimes.list()
.filter((snapshot) => snapshot.state === 'running')
.flatMap((snapshot) => {
const runtime = this.runtimes.get(snapshot.accountId);
return runtime ? [runtime] : [];
});
}
private async findExistingConversation(
runtimes: RedditAccountRuntime[],
username: string,
): Promise<RuntimeConversation | null> {
const normalized = normalizeUsername(username);
const matches = (await Promise.all(runtimes.map(async (runtime) =>
(await runtime.resources.chatService.getCachedConversations())
.filter((conversation) => normalizeUsername(conversation.username) === normalized)
.map((conversation) => ({ runtime, conversation })),
))).flat();
matches.sort((left, right) => right.conversation.lastMessageTime - left.conversation.lastMessageTime
|| left.runtime.session.redditUsername.localeCompare(right.runtime.session.redditUsername));
return matches[0] ?? null;
}
private async sendExisting(
selected: RuntimeConversation,
username: string,
message: string,
): Promise<RedditUserMessageResult> {
const { runtime, conversation } = selected;
if (!conversation.url || !conversation.roomId) {
throw new RedditUserMessageError(409, 'The existing conversation is missing its Matrix room details; synchronize chats and retry');
}
const sent = await runtime.resources.rateLimiter.throttle(
() => runtime.resources.chatService.sendChatMessage(conversation.url!, message),
(success) => success,
);
if (!sent) throw new RedditUserMessageError(502, 'Reddit did not confirm the message send');
const currentMessages = (conversation.messages ?? []).map(({ sender, text, isSelf }) => ({ sender, text, isSelf }));
const state = mergeConversationState(
runtime.resources.conversationStateRepo.get(conversation.roomId),
buildConversationState([...currentMessages, { sender: 'Me', text: message, isSelf: true }]),
);
state.automation.status = 'user-overridden';
state.automation.handoffReason = 'An API message was sent in this conversation.';
runtime.resources.conversationStateRepo.save(conversation.roomId, state);
this.recordSend(runtime, username, `Sent chat message to u/${username}`);
void runtime.resources.chatService.refreshTopChats(5).catch(() => undefined);
return resultFor(runtime, username, conversation.roomId, true);
}
private recordSend(runtime: RedditAccountRuntime, username: string, description: string): void {
runtime.resources.activityLogRepo.insert({
timestamp: Date.now(),
type: 'sent',
message: description,
recipient: username,
});
}
}
export class RedditUserMessageError extends Error {
constructor(readonly status: number, message: string) {
super(message);
}
}
function compareAvailableRuntimes(left: RedditAccountRuntime, right: RedditAccountRuntime): number {
return left.resources.rateLimiter.getStats().messagesThisHour
- right.resources.rateLimiter.getStats().messagesThisHour
|| left.session.redditUsername.localeCompare(right.session.redditUsername);
}
function normalizeUsername(username: string): string {
return username.trim().replace(/^u\//i, '').toLocaleLowerCase('en-US');
}
function resultFor(
runtime: RedditAccountRuntime,
recipient: string,
conversationId: string,
reusedConversation: boolean,
): RedditUserMessageResult {
return {
accountId: runtime.session.accountId,
redditUsername: runtime.session.redditUsername,
recipient: recipient.trim().replace(/^u\//i, ''),
conversationId,
reusedConversation,
};
}
+13
View File
@@ -94,6 +94,19 @@ export const ControlResponseSchema = z.strictObject({
message: z.string(),
});
export const SendRedditUserMessageRequestSchema = z.strictObject({
username: z.string().trim().min(1).max(64),
message: z.string().trim().min(1).max(10_000),
});
export const SendRedditUserMessageResponseSchema = z.strictObject({
accountId: z.string(),
redditUsername: z.string(),
recipient: z.string(),
conversationId: z.string(),
reusedConversation: z.boolean(),
});
export const AgentJobRequestSchema = z.discriminatedUnion('type', [
z.strictObject({
type: z.literal('chat-sync'),
+3
View File
@@ -8,6 +8,8 @@ import {
HealthResponseSchema,
OverviewResponseSchema,
ReadinessResponseSchema,
SendRedditUserMessageRequestSchema,
SendRedditUserMessageResponseSchema,
} from './agent-api-contracts.js';
export type ApiAccess = 'read' | 'control' | 'admin';
@@ -66,6 +68,7 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
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('sendRedditUserMessage', 'POST', '/api/v1/messages', 'Send a message to a Reddit user with automatic account selection', ['Agent', 'Conversations'], { access: 'admin', requestSchema: SendRedditUserMessageRequestSchema, responseSchema: SendRedditUserMessageResponseSchema }),
operation('getStats', 'GET', '/api/stats', 'Get bot statistics', ['Dashboard']),
operation('getCandidateFunnel', 'GET', '/api/analytics/candidate-funnel', 'Get durable candidate funnel counts', ['Analytics']),
+24 -1
View File
@@ -1,9 +1,10 @@
import { Router } from 'express';
import type { WebContext } from '../context.js';
import { AgentJobRequestSchema, ControlRequestSchema } from '../agent-api-contracts.js';
import { AgentJobRequestSchema, ControlRequestSchema, SendRedditUserMessageRequestSchema } 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';
import { RedditUserMessageError, RedditUserMessenger } from '../../services/reddit-user-messenger.js';
export function createHealthRouter(startedAt = Date.now()): Router {
const router = Router();
@@ -30,6 +31,28 @@ export function createAgentRouter(ctx: WebContext): Router {
});
router.get('/v1/overview', (_req, res) => res.json(buildOverview(ctx)));
router.post('/v1/messages', async (req, res) => {
const body = SendRedditUserMessageRequestSchema.safeParse(req.body);
if (!body.success) {
res.status(400).json({ error: 'Invalid message request', detail: body.error.format() });
return;
}
if (!ctx.services.accountRuntimes) {
res.status(503).json({ error: 'Reddit account runtimes are unavailable' });
return;
}
try {
const result = await new RedditUserMessenger(ctx.services.accountRuntimes)
.send(body.data.username, body.data.message);
ctx.broadcaster.push('info', `API message sent to u/${result.recipient} via u/${result.redditUsername}`);
res.json(result);
} catch (error) {
ctx.logger.error('Failed to send Reddit user message:', error);
const status = error instanceof RedditUserMessageError ? error.status : 500;
res.status(status).json({ error: error instanceof Error ? error.message : String(error) });
}
});
router.get('/v1/control', (_req, res) => res.json(botControlStatus(ctx)));
router.post('/v1/control', (req, res) => {
const body = ControlRequestSchema.safeParse(req.body);
+20 -1
View File
@@ -1,6 +1,11 @@
import { Router } from 'express';
import type { WebContext } from '../context.js';
import { ConfigSchema, SubredditConfigSchema, type SubredditConfig } from '../../config/index.js';
import {
ConfigSchema,
PhysiqueTargetingSchema,
SubredditConfigSchema,
type SubredditConfig,
} from '../../config/index.js';
import { TONE_KEYS } from '../../config/tone-profiles.js';
import { z } from 'zod';
@@ -75,6 +80,16 @@ export function createConfigRouter(ctx: WebContext): Router {
res.status(400).json({ error: 'Invalid advanced configuration', detail: parsedAdvanced.error.format() });
return;
}
const parsedPhysiqueTargeting = updates.reddit?.physiqueTargeting === undefined
? null
: PhysiqueTargetingSchema.safeParse(updates.reddit.physiqueTargeting);
if (parsedPhysiqueTargeting && !parsedPhysiqueTargeting.success) {
res.status(400).json({
error: 'Invalid physique targeting configuration',
detail: parsedPhysiqueTargeting.error.format(),
});
return;
}
if (updates.rateLimit) {
if (typeof updates.rateLimit.minDelayBetweenMessagesMs === 'number') {
@@ -113,6 +128,9 @@ export function createConfigRouter(ctx: WebContext): Router {
&& updates.reddit.maxReadyCandidates <= 500) {
ctx.runtimeConfig.reddit.maxReadyCandidates = updates.reddit.maxReadyCandidates;
}
if (parsedPhysiqueTargeting?.success) {
Object.assign(ctx.runtimeConfig.reddit.physiqueTargeting, parsedPhysiqueTargeting.data);
}
}
// Mirror into the loaded config so getStats reflects new values.
@@ -123,6 +141,7 @@ export function createConfigRouter(ctx: WebContext): Router {
ctx.config.reddit.prioritizeRecentPosts = ctx.runtimeConfig.reddit.prioritizeRecentPosts;
ctx.config.reddit.recentWindowMinutes = ctx.runtimeConfig.reddit.recentWindowMinutes;
ctx.config.reddit.maxReadyCandidates = ctx.runtimeConfig.reddit.maxReadyCandidates;
Object.assign(ctx.config.reddit.physiqueTargeting, ctx.runtimeConfig.reddit.physiqueTargeting);
if (parsedSubreddits?.success) {
const next: SubredditConfig[] = parsedSubreddits.data;
+34
View File
@@ -20,6 +20,23 @@ interface OutreachCandidateView {
availableAt: number;
attemptCount: number;
hasPreparedMessage: boolean;
physiqueScore: number | null;
preparation: {
message: string;
imageAnalysis: string;
detectedGender: 'male' | 'female' | 'other' | 'unknown';
attractivenessScore: number | null;
physiqueScore: number | null;
physiqueTargeting: { enabled: boolean; minScore: number; maxScore: number } | null;
sourceImageUrl: string | null;
strategyId: string | null;
experimentId: string | null;
promptVersion: string | null;
selectionReason: 'explore' | 'exploit' | null;
modelId: string;
toneId: string;
scripted: boolean;
} | null;
note: string | null;
owner: { accountId: string; redditUsername: string } | null;
}
@@ -61,6 +78,23 @@ export function createOutreachRouter(ctx: WebContext): Router {
availableAt: candidate.availableAt,
attemptCount: candidate.attemptCount,
hasPreparedMessage: candidate.prepared !== null,
physiqueScore: candidate.prepared?.physiqueScore ?? null,
preparation: candidate.prepared ? {
message: candidate.prepared.generatedMessage,
imageAnalysis: candidate.prepared.imageAnalysis,
detectedGender: candidate.prepared.detectedGender,
attractivenessScore: candidate.prepared.attractivenessScore ?? null,
physiqueScore: candidate.prepared.physiqueScore ?? null,
physiqueTargeting: candidate.prepared.physiqueTargeting ?? null,
sourceImageUrl: candidate.prepared.sourceImageUrl ?? null,
strategyId: candidate.prepared.strategyId,
experimentId: candidate.prepared.experimentId,
promptVersion: candidate.prepared.promptVersion,
selectionReason: candidate.prepared.selectionReason,
modelId: candidate.prepared.modelId,
toneId: candidate.prepared.toneId,
scripted: candidate.prepared.scripted,
} : null,
note: candidate.lastError,
owner: candidate.assignedAccountId
? {
+1
View File
@@ -103,6 +103,7 @@ export class WebServer {
prioritizeRecentPosts: config.reddit.prioritizeRecentPosts,
recentWindowMinutes: config.reddit.recentWindowMinutes,
maxReadyCandidates: config.reddit.maxReadyCandidates,
physiqueTargeting: { ...config.reddit.physiqueTargeting },
},
};
this.app = express();
+5
View File
@@ -66,5 +66,10 @@ export interface RuntimeConfig {
prioritizeRecentPosts: boolean;
recentWindowMinutes: number;
maxReadyCandidates: number;
physiqueTargeting: {
enabled: boolean;
minScore: number;
maxScore: number;
};
};
}
+6
View File
@@ -11,6 +11,7 @@ describe('agent API discovery', () => {
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('POST /api/v1/messages');
expect(keys).toContain('GET /api/events/stream');
});
@@ -39,6 +40,7 @@ describe('agent API discovery', () => {
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();
expect(document.paths['/api/v1/messages'].post.requestBody).toBeDefined();
});
it('returns a compact capability catalogue for agents', () => {
@@ -48,5 +50,9 @@ describe('agent API discovery', () => {
access: 'admin',
mutates: true,
});
expect(result.operations.find((item) => item.operationId === 'sendRedditUserMessage')).toMatchObject({
access: 'admin',
mutates: true,
});
});
});
+4
View File
@@ -31,6 +31,10 @@ function prepared(candidate: RedditPost): ProcessedPost {
post: candidate,
imageAnalysis: 'analysis',
detectedGender: 'female',
attractivenessScore: 64,
physiqueScore: 5,
physiqueTargeting: { enabled: true, minScore: 3, maxScore: 7 },
sourceImageUrl: 'https://i.redd.it/example.jpg',
generatedMessage: 'prepared message',
strategyId: null,
experimentId: null,
+26 -4
View File
@@ -113,6 +113,7 @@ describe('dashboard foundation', () => {
it('shows the live outreach stages and the potential posts being burned down', async () => {
installSuccessfulApi();
const user = userEvent.setup();
server.use(
http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: true, status: 'authenticated' })),
http.get('/api/chats/sync', () => HttpResponse.json({
@@ -131,6 +132,17 @@ describe('dashboard foundation', () => {
status: 'ready', priority: 2, postCreatedAt: Date.parse('2026-08-09T06:50:00Z'),
discoveredAt: Date.parse('2026-08-09T07:00:00Z'), updatedAt: Date.parse('2026-08-09T07:30:00Z'),
availableAt: Date.parse('2026-08-09T07:30:00Z'), attemptCount: 1, hasPreparedMessage: true,
physiqueScore: 6,
preparation: {
message: 'You look incredible in that pose — what inspired this post?',
imageAnalysis: 'VISIBLE_FACTS:\nBODY: VISIBLE - full-body pose',
detectedGender: 'female', attractivenessScore: 68, physiqueScore: 6,
physiqueTargeting: { enabled: true, minScore: 4, maxScore: 7 },
sourceImageUrl: 'https://i.redd.it/candidate-1.jpg',
strategyId: 'direct-observation', experimentId: 'strategies:direct-observation:abc123',
promptVersion: 'abc123', selectionReason: 'exploit', modelId: 'example/image-model',
toneId: 'direct', scripted: false,
},
note: null, owner: null,
}],
})),
@@ -141,11 +153,21 @@ describe('dashboard foundation', () => {
expect(await screen.findByRole('heading', { name: 'Outreach funnel' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '3 active posts' })).toBeInTheDocument();
expect(screen.getByRole('list', { name: 'Active outreach candidates' })).toHaveTextContent('A potential post');
expect(screen.getByRole('link', { name: '[25F] A potential post' })).toHaveAttribute(
'href',
'https://www.reddit.com/r/example/comments/candidate-1/',
);
expect(screen.getAllByRole('link', { name: '[25F] A potential post' }))
.toEqual(expect.arrayContaining([
expect.objectContaining({ href: 'https://www.reddit.com/r/example/comments/candidate-1/' }),
]));
expect(within(screen.getByRole('list', { name: 'Active outreach candidates' })).getByText('Message prepared')).toBeInTheDocument();
const preparation = screen.getByRole('region', { name: 'Prepared outreach for u/alice' });
expect(preparation).toHaveTextContent('You look incredible in that pose');
expect(preparation).toHaveTextContent('68/100');
expect(preparation).toHaveTextContent('6/10');
expect(preparation).toHaveTextContent('Target 4–7');
expect(preparation).toHaveTextContent('example/image-model');
const sourceImage = within(preparation).getByRole('img', { name: 'Source post: [25F] A potential post' });
expect(sourceImage).toHaveClass('is-blurred');
await user.click(within(preparation).getByRole('button', { name: 'Reveal source image: [25F] A potential post' }));
expect(sourceImage).not.toHaveClass('is-blurred');
});
it('hydrates media filters from the routed URL', async () => {
+46 -1
View File
@@ -13,7 +13,12 @@ import { SettingsRoute } from '../../apps/dashboard/src/features/settings/settin
const config = {
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 },
browser: { dryRun: true },
reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60, maxReadyCandidates: 25 },
reddit: {
prioritizeRecentPosts: false,
recentWindowMinutes: 60,
maxReadyCandidates: 25,
physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 },
},
subreddits: [{ name: 'casualconversation', type: 'chat', weight: 2, tone: 'default' }],
tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'],
advanced: { openrouter: { chatModel: 'example/chat-model' } },
@@ -204,6 +209,46 @@ describe('settings and access', () => {
expect(screen.queryByText('Settings saved.')).not.toBeInTheDocument();
});
it('validates and saves the physique targeting range', async () => {
installConfigApi();
let requestBody: { reddit?: typeof config.reddit } | undefined;
server.use(http.put('/api/config', async ({ request }) => {
requestBody = await request.json() as typeof requestBody;
return HttpResponse.json({
success: true,
config: {
rateLimit: config.rateLimit,
browser: config.browser,
reddit: requestBody?.reddit ?? config.reddit,
},
subreddits: config.subreddits,
});
}));
const user = userEvent.setup();
renderSettings('/settings?section=monitoring');
await user.click(await screen.findByRole('checkbox', { name: 'Enable physique targeting' }));
const minimum = screen.getByRole('spinbutton', { name: 'Minimum physique score' });
const maximum = screen.getByRole('spinbutton', { name: 'Maximum physique score' });
await user.clear(minimum);
await user.type(minimum, '7');
await user.clear(maximum);
await user.type(maximum, '4');
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(screen.getByText('Minimum physique score cannot exceed maximum physique score.')).toBeInTheDocument();
expect(requestBody).toBeUndefined();
await user.clear(maximum);
await user.type(maximum, '9');
await user.click(screen.getByRole('button', { name: 'Save settings' }));
await waitFor(() => expect(requestBody?.reddit?.physiqueTargeting).toEqual({
enabled: true,
minScore: 7,
maxScore: 9,
}));
});
it('validates subreddit rows and confirms disabling dry run before saving behaviour', async () => {
installConfigApi();
let updateRequests = 0;
+10 -1
View File
@@ -29,10 +29,11 @@ describe('RedditBot MCP server', () => {
expect(names).toEqual(expect.arrayContaining([
'get_overview', 'list_accounts', 'list_conversations', 'start_bot', 'stop_bot',
'set_bot_running', 'trigger_sync', 'send_reply', 'start_conversation',
'set_bot_running', 'trigger_sync', 'send_reply', 'send_message_to_user', '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);
expect(tools.tools.find((tool) => tool.name === 'send_message_to_user')?.annotations?.destructiveHint).toBe(true);
});
it('routes start and stop tools to the versioned control API', async () => {
@@ -44,4 +45,12 @@ describe('RedditBot MCP server', () => {
expect(post).toHaveBeenNthCalledWith(1, '/api/v1/control/start');
expect(post).toHaveBeenNthCalledWith(2, '/api/v1/control/stop');
});
it('routes automatic user messages to the versioned messaging API', async () => {
const { client, post } = await connectedClient();
await client.callTool({ name: 'send_message_to_user', arguments: { username: 'alice', message: 'hello' } });
expect(post).toHaveBeenCalledWith('/api/v1/messages', { username: 'alice', message: 'hello' });
});
});
+20
View File
@@ -315,6 +315,26 @@ describe('OpenRouterClient', () => {
expect(result.attractivenessScore).toBeNull();
});
it('parses and removes a grounded PHYSIQUE_SCORE', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'GENDER:female\nPHYSIQUE_SCORE:7\nRATING:73\n\nDescription here.' } }],
});
const result = await client.analyzeImage('base64data', 'image/jpeg');
expect(result.physiqueScore).toBe(7);
expect(result.description).toBe('Description here.');
});
it('returns null for PHYSIQUE_SCORE:UNKNOWN', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'GENDER:female\nPHYSIQUE_SCORE:UNKNOWN\n\nClose-cropped portrait.' } }],
});
const result = await client.analyzeImage('base64data', 'image/jpeg');
expect(result.physiqueScore).toBeNull();
expect(result.description).toBe('Close-cropped portrait.');
});
it('handles case-insensitive GENDER prefix', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'GENDER:FEMALE\n\nA woman.' } }],
+75 -19
View File
@@ -24,13 +24,18 @@ function createMockRedditApi(imageUrl: string | null = 'http://example.com/img.j
function createMockAiClient(
gender: DetectedGender = 'female',
analysis: ImageAnalysisResult = { description: 'Beautiful woman', detectedGender: 'female', attractivenessScore: null },
analysis: ImageAnalysisResult = {
description: 'Beautiful woman',
detectedGender: 'female',
attractivenessScore: null,
physiqueScore: null,
},
message: string = 'Hey gorgeous!'
): OpenRouterClient {
return {
detectGenderOnly: vi.fn(async () => gender),
analyzeImage: vi.fn(async () => analysis),
generateMessage: vi.fn(async (analysis: string) => message),
generateMessage: vi.fn(async () => message),
generateReply: vi.fn(async () => 'Reply'),
detectUpsell: vi.fn(() => ({ isUpsell: false })),
analyzeAndGenerateMessage: vi.fn(async (base64: string, mimeType: string, title: string, author: string, examples?: string[], strategyPrompt?: string, postFlair?: string | null, toneGuidance?: string) => {
@@ -123,9 +128,60 @@ describe('ProcessorService', () => {
expect(isSkip(result)).toBe(false);
});
it('screens out a scored image outside the enabled physique range before generating a message', async () => {
aiClient = createMockAiClient('female', {
description: 'Visible full-body pose',
detectedGender: 'female',
attractivenessScore: 72,
physiqueScore: 8,
});
const config = getTestConfig({
reddit: { physiqueTargeting: { enabled: true, minScore: 3, maxScore: 6 } },
});
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
const result = await processor.processPost(mockPost);
expect(result).toEqual({ skipReason: 'physique score 8 outside target range 3-6' });
expect(aiClient.generateMessage).not.toHaveBeenCalled();
});
it('screens out an unscorable image when physique targeting is enabled', async () => {
const config = getTestConfig({
reddit: { physiqueTargeting: { enabled: true, minScore: 3, maxScore: 6 } },
});
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
const result = await processor.processPost(mockPost);
expect(result).toEqual({
skipReason: 'physique score unavailable while physique targeting is enabled',
});
expect(aiClient.generateMessage).not.toHaveBeenCalled();
});
it('retains the physique score when it falls inside the enabled range', async () => {
aiClient = createMockAiClient('female', {
description: 'Visible full-body pose',
detectedGender: 'female',
attractivenessScore: 66,
physiqueScore: 5,
});
const config = getTestConfig({
reddit: { physiqueTargeting: { enabled: true, minScore: 3, maxScore: 6 } },
});
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
const result = await processor.processPost(mockPost);
if (isSkip(result)) throw new Error(`expected success, got skip: ${result.skipReason}`);
expect(result.physiqueScore).toBe(5);
expect(aiClient.generateMessage).toHaveBeenCalledOnce();
});
it('skips when AI refuses', async () => {
const error = new Error('AI_REFUSAL: All models refused to analyze image');
vi.mocked(aiClient.analyzeAndGenerateMessage).mockRejectedValue(error);
vi.mocked(aiClient.analyzeImage).mockRejectedValue(error);
const result = await processor.processPost(mockPost);
expect(isSkip(result)).toBe(true);
@@ -134,14 +190,14 @@ describe('ProcessorService', () => {
it('re-throws non-AI_REFUSAL errors', async () => {
const error = new Error('Network error');
vi.mocked(aiClient.analyzeAndGenerateMessage).mockRejectedValue(error);
vi.mocked(aiClient.analyzeImage).mockRejectedValue(error);
await expect(processor.processPost(mockPost)).rejects.toThrow('Network error');
});
it('skips when generated message is too short', async () => {
aiClient = createMockAiClient('female',
{ description: 'Nice', detectedGender: 'female' },
{ description: 'Nice', detectedGender: 'female', attractivenessScore: null, physiqueScore: null },
'Short'
);
const config = getTestConfig();
@@ -154,7 +210,7 @@ describe('ProcessorService', () => {
it('skips when generated message is empty', async () => {
aiClient = createMockAiClient('female',
{ description: 'Nice', detectedGender: 'female' },
{ description: 'Nice', detectedGender: 'female', attractivenessScore: null, physiqueScore: null },
''
);
const config = getTestConfig();
@@ -188,13 +244,13 @@ describe('ProcessorService', () => {
await processor.processPost(mockPost);
expect(aiClient.analyzeAndGenerateMessage).toHaveBeenCalledWith(
expect.any(String), // base64
'image/jpeg',
expect(aiClient.generateMessage).toHaveBeenCalledWith(
'Beautiful woman',
'Test Post',
'testuser',
['Example 1: Hey', 'Example 2: Nice'],
undefined,
null,
undefined,
undefined,
[],
@@ -211,13 +267,13 @@ describe('ProcessorService', () => {
await processor.processPost(mockPost);
expect(aiClient.analyzeAndGenerateMessage).toHaveBeenCalledWith(
expect.any(String),
'image/jpeg',
expect(aiClient.generateMessage).toHaveBeenCalledWith(
'Beautiful woman',
'Test Post',
'testuser',
[],
'Be teasing',
null,
undefined,
undefined,
[],
@@ -238,13 +294,13 @@ describe('ProcessorService', () => {
await processor.processPost(mockPost);
expect(aiClient.analyzeAndGenerateMessage).toHaveBeenCalledWith(
expect.any(String),
'image/jpeg',
expect(aiClient.generateMessage).toHaveBeenCalledWith(
'Beautiful woman',
'Test Post',
'testuser',
[],
undefined,
null,
undefined,
undefined,
recentMessages,
@@ -281,13 +337,13 @@ describe('ProcessorService', () => {
await processor.processPost(mockPost);
expect(aiClient.analyzeAndGenerateMessage).toHaveBeenCalledWith(
expect.any(String),
'image/jpeg',
expect(aiClient.generateMessage).toHaveBeenCalledWith(
'Beautiful woman',
'Test Post',
'testuser',
['Example A'],
undefined,
null,
undefined,
undefined,
[],
@@ -428,7 +484,7 @@ describe('ProcessorService', () => {
await processor.processPost(tonedPost);
// 8th arg (index 7) after strategyPrompt and postFlair is toneGuidance
const callArgs = vi.mocked(aiClient.analyzeAndGenerateMessage).mock.calls[0];
const callArgs = vi.mocked(aiClient.generateMessage).mock.calls[0];
expect(callArgs[7]).toContain('STYLE TONE');
expect(callArgs[7]).toContain('commanding');
});
+3 -1
View File
@@ -12,7 +12,9 @@ describe('materializePreparedPostForAccount', () => {
thumbnail: '', is_self: false, created_utc: 123, over_18: true, spoiler: false,
};
const processed: ProcessedPost = {
post, imageAnalysis: 'analysis', detectedGender: 'female', generatedMessage: 'message',
post, imageAnalysis: 'analysis', detectedGender: 'female', attractivenessScore: 64,
physiqueScore: 5, physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 },
sourceImageUrl: 'https://example.com/image.jpg', generatedMessage: 'message',
strategyId: null, experimentId: null, promptVersion: null, selectionReason: null,
modelId: 'model', sourceType: 'image', toneId: 'default', scripted: false,
};
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from 'vitest';
import { RedditUserMessenger } from '../src/services/reddit-user-messenger.js';
import type { RedditAccountRuntime, RedditAccountRuntimeManager } from '../src/services/reddit-account-runtime.js';
function runtime(accountId: string, redditUsername: string, conversations: Array<Record<string, unknown>>, sends = 0) {
return {
session: { accountId, redditUsername },
resources: {
accountAuth: { isFullyAuthenticated: () => true },
chatService: {
getCachedConversations: vi.fn().mockResolvedValue(conversations),
getInvitationCooldown: () => null,
sendChatMessage: vi.fn().mockResolvedValue(true),
startConversationDetailed: vi.fn().mockResolvedValue({
status: 'sent',
chat: { username: 'new-user', roomId: 'new-room', messages: [] },
}),
refreshTopChats: vi.fn().mockResolvedValue(undefined),
},
rateLimiter: {
getStats: () => ({ messagesThisHour: sends }),
throttle: vi.fn((work: () => Promise<unknown>) => work()),
},
activityLogRepo: { insert: vi.fn() },
conversationStateRepo: { get: vi.fn(), save: vi.fn() },
},
} as unknown as RedditAccountRuntime;
}
function manager(items: RedditAccountRuntime[]) {
return {
list: () => items.map((item) => ({
accountId: item.session.accountId,
redditUsername: item.session.redditUsername,
matrixUserId: item.session.accountId,
state: 'running',
updatedAt: 1,
failure: null,
})),
get: (accountId: string) => items.find((item) => item.session.accountId === accountId) ?? null,
} as unknown as RedditAccountRuntimeManager;
}
describe('RedditUserMessenger', () => {
it('prefers the account with the most recent existing interaction', async () => {
const first = runtime('one', 'owner-one', [{
username: 'Alice', roomId: 'old-room', url: '/room/old-room', lastMessageTime: 10,
messages: [],
}]);
const second = runtime('two', 'owner-two', [{
username: 'u/alice', roomId: 'recent-room', url: '/room/recent-room', lastMessageTime: 20,
messages: [],
}]);
const result = await new RedditUserMessenger(manager([first, second])).send('u/Alice', 'hello');
expect(result).toMatchObject({ accountId: 'two', conversationId: 'recent-room', reusedConversation: true });
expect(second.resources.chatService.sendChatMessage).toHaveBeenCalledWith('/room/recent-room', 'hello');
expect(first.resources.chatService.sendChatMessage).not.toHaveBeenCalled();
expect(second.resources.conversationStateRepo.save).toHaveBeenCalledWith('recent-room', expect.objectContaining({
automation: expect.objectContaining({ status: 'user-overridden' }),
}));
});
it('starts a new conversation on the running account with the lowest send count', async () => {
const busy = runtime('busy', 'z-owner', [], 4);
const available = runtime('available', 'a-owner', [], 1);
const result = await new RedditUserMessenger(manager([busy, available])).send('new-user', 'hello');
expect(result).toMatchObject({ accountId: 'available', conversationId: 'new-room', reusedConversation: false });
expect(available.resources.chatService.startConversationDetailed).toHaveBeenCalledWith('new-user', 'hello');
expect(busy.resources.chatService.startConversationDetailed).not.toHaveBeenCalled();
});
});
+1
View File
@@ -44,6 +44,7 @@ export function getTestConfig(overrides?: Partial<Config>): Config {
prioritizeRecentPosts: false,
recentWindowMinutes: 60,
maxReadyCandidates: 25,
physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 },
...overrides?.reddit,
},
openrouter: {
+40
View File
@@ -109,3 +109,43 @@ describe('config route conversation kill controls', () => {
expect(config.conversations.aiReplyScreening.enabled).toBe(false);
});
});
describe('config route physique targeting', () => {
it('hot-updates and persists the image-candidate score range', async () => {
const { app, config, saved } = harness();
const targetingRef = config.reddit.physiqueTargeting;
const response = await request(app, '/api/config', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
reddit: {
physiqueTargeting: { enabled: true, minScore: 3, maxScore: 7 },
},
}),
});
expect(response.status).toBe(200);
expect(config.reddit.physiqueTargeting).toBe(targetingRef);
expect(config.reddit.physiqueTargeting).toEqual({ enabled: true, minScore: 3, maxScore: 7 });
expect(saved.at(-1)?.reddit.physiqueTargeting).toEqual({ enabled: true, minScore: 3, maxScore: 7 });
});
it('rejects an inverted physique range without mutating configuration', async () => {
const { app, config, saved } = harness();
const response = await request(app, '/api/config', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
reddit: {
physiqueTargeting: { enabled: true, minScore: 8, maxScore: 4 },
},
}),
});
expect(response.status).toBe(400);
expect(config.reddit.physiqueTargeting).toEqual({ enabled: false, minScore: 1, maxScore: 10 });
expect(saved).toHaveLength(0);
});
});
+25 -3
View File
@@ -20,7 +20,7 @@ afterEach(async () => {
});
describe('outreach route', () => {
it('publishes active candidates and queue outcomes without prepared message content', async () => {
it('publishes prepared candidate context without leaking worker lease internals', async () => {
const candidateQueue = {
getStats: vi.fn(() => ({
total: 9,
@@ -37,7 +37,22 @@ describe('outreach route', () => {
title: '[25F] Hello', url: 'https://i.redd.it/post-1.jpg', permalink: '/r/example/comments/post-1/hello/',
thumbnail: '', is_self: false, created_utc: 100, over_18: true, spoiler: false,
},
prepared: { generatedMessage: 'private prepared text' }, priority: 2, status: 'sending',
prepared: {
generatedMessage: 'Prepared opener for Alice',
imageAnalysis: 'VISIBLE_FACTS:\nBODY: VISIBLE - full-body pose',
detectedGender: 'female',
attractivenessScore: 68,
physiqueScore: 6,
physiqueTargeting: { enabled: true, minScore: 4, maxScore: 7 },
sourceImageUrl: 'https://i.redd.it/post-1.jpg',
strategyId: 'direct-observation',
experimentId: 'strategies:direct-observation:abc123',
promptVersion: 'abc123',
selectionReason: 'exploit',
modelId: 'example/image-model',
toneId: 'direct',
scripted: false,
}, priority: 2, status: 'sending',
discoveredAt: 1_000, updatedAt: 2_000, availableAt: 2_000,
leaseOwner: 'worker-secret', leaseExpiresAt: 3_000, assignedAccountId: 'account-a',
attemptCount: 2, lastError: null,
@@ -64,9 +79,16 @@ describe('outreach route', () => {
postId: 'post-1', status: 'sending', hasPreparedMessage: true,
postUrl: 'https://www.reddit.com/r/example/comments/post-1/hello/',
owner: { accountId: 'account-a', redditUsername: 'operator' },
preparation: {
message: 'Prepared opener for Alice',
attractivenessScore: 68,
physiqueScore: 6,
physiqueTargeting: { enabled: true, minScore: 4, maxScore: 7 },
sourceImageUrl: 'https://i.redd.it/post-1.jpg',
strategyId: 'direct-observation',
},
}],
});
expect(JSON.stringify(body)).not.toContain('private prepared text');
expect(JSON.stringify(body)).not.toContain('worker-secret');
});