feat: expand outreach targeting and messaging
This commit is contained in:
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 { useQuery } from '@tanstack/react-query';
|
||||||
import type { OutreachCandidate, OutreachCandidateStatus } from '../../../../../packages/api-contracts/src/index.js';
|
import type { OutreachCandidate, OutreachCandidateStatus } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import { AccountOwnerBadge } from '../../components/account-owner-badge.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 { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||||
import { formatExactTime, formatShortRelativeTime } from '../conversations/relative-time.js';
|
import { formatExactTime, formatShortRelativeTime } from '../conversations/relative-time.js';
|
||||||
import { outreachApi } from './outreach-api.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`;
|
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 }) {
|
function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now: number }) {
|
||||||
const waitingUntil = candidate.availableAt > now;
|
const waitingUntil = candidate.availableAt > now;
|
||||||
const isKnownStatus = KNOWN_STATUSES.has(candidate.status);
|
const isKnownStatus = KNOWN_STATUSES.has(candidate.status);
|
||||||
@@ -61,11 +108,13 @@ function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now:
|
|||||||
<div className="candidate-meta">
|
<div className="candidate-meta">
|
||||||
<RedditUsernameLink username={candidate.author} />
|
<RedditUsernameLink username={candidate.author} />
|
||||||
<span>{candidate.hasPreparedMessage ? 'Message prepared' : 'Not prepared yet'}</span>
|
<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>
|
<span>{candidate.attemptCount} {candidate.attemptCount === 1 ? 'attempt' : 'attempts'}</span>
|
||||||
{waitingUntil ? <span title={formatExactTime(candidate.availableAt)}>Retry {formatWaitTime(candidate.availableAt, now)}</span> : null}
|
{waitingUntil ? <span title={formatExactTime(candidate.availableAt)}>Retry {formatWaitTime(candidate.availableAt, now)}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
{candidate.note ? <p className="candidate-note"><strong>Latest note</strong>{candidate.note}</p> : null}
|
{candidate.note ? <p className="candidate-note"><strong>Latest note</strong>{candidate.note}</p> : null}
|
||||||
{candidate.owner ? <AccountOwnerBadge owner={candidate.owner} /> : null}
|
{candidate.owner ? <AccountOwnerBadge owner={candidate.owner} /> : null}
|
||||||
|
<PreparedCandidateDetails candidate={candidate} />
|
||||||
</article>
|
</article>
|
||||||
</li>
|
</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 { 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; }
|
.candidate-note strong { color: var(--warning-text); font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; }
|
||||||
.outreach-candidate .account-owner-badge { margin-top: -.1rem; }
|
.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) {
|
@media (min-width: 64rem) {
|
||||||
.outreach-stages { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; }
|
.outreach-stages { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; }
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export interface SettingsDraft {
|
|||||||
prioritizeRecentPosts: boolean;
|
prioritizeRecentPosts: boolean;
|
||||||
recentWindowMinutes: string;
|
recentWindowMinutes: string;
|
||||||
maxReadyCandidates: string;
|
maxReadyCandidates: string;
|
||||||
|
physiqueTargetingEnabled: boolean;
|
||||||
|
physiqueMinScore: string;
|
||||||
|
physiqueMaxScore: string;
|
||||||
subreddits: SubredditDraft[];
|
subreddits: SubredditDraft[];
|
||||||
advancedConfigJson: string;
|
advancedConfigJson: string;
|
||||||
}
|
}
|
||||||
@@ -57,12 +60,22 @@ const ValidatedSettingsSchema = z.strictObject({
|
|||||||
prioritizeRecentPosts: z.boolean(),
|
prioritizeRecentPosts: z.boolean(),
|
||||||
recentWindowMinutes: z.number().finite().positive('Recent window must be greater than 0 minutes.'),
|
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.'),
|
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.'),
|
subreddits: z.array(EditableSubredditSchema).min(1, 'At least one subreddit is required.'),
|
||||||
}).superRefine((value, context) => {
|
}).superRefine((value, context) => {
|
||||||
const names = value.subreddits.map((subreddit) => subreddit.name.trim().toLowerCase());
|
const names = value.subreddits.map((subreddit) => subreddit.name.trim().toLowerCase());
|
||||||
if (new Set(names).size !== names.length) {
|
if (new Set(names).size !== names.length) {
|
||||||
context.addIssue({ code: 'custom', path: ['subreddits'], message: 'Subreddit names must be unique.' });
|
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;
|
let nextRowId = 0;
|
||||||
@@ -81,6 +94,9 @@ export function createSettingsDraft(config: ConfigResponse): SettingsDraft {
|
|||||||
prioritizeRecentPosts: config.reddit.prioritizeRecentPosts,
|
prioritizeRecentPosts: config.reddit.prioritizeRecentPosts,
|
||||||
recentWindowMinutes: String(config.reddit.recentWindowMinutes),
|
recentWindowMinutes: String(config.reddit.recentWindowMinutes),
|
||||||
maxReadyCandidates: String(config.reddit.maxReadyCandidates),
|
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) => ({
|
subreddits: config.subreddits.map((subreddit) => ({
|
||||||
rowId: createSubredditRowId(),
|
rowId: createSubredditRowId(),
|
||||||
name: subreddit.name,
|
name: subreddit.name,
|
||||||
@@ -104,6 +120,8 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
|
|||||||
if (draft.jitterSeconds.trim() === '') requiredErrors.jitterSeconds = 'Jitter is required.';
|
if (draft.jitterSeconds.trim() === '') requiredErrors.jitterSeconds = 'Jitter is required.';
|
||||||
if (draft.recentWindowMinutes.trim() === '') requiredErrors.recentWindowMinutes = 'Recent window 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.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) => {
|
draft.subreddits.forEach((subreddit, index) => {
|
||||||
if (subreddit.name.trim() === '') requiredErrors[`subreddits.${index}.name`] = 'Subreddit name is required.';
|
if (subreddit.name.trim() === '') requiredErrors[`subreddits.${index}.name`] = 'Subreddit name is required.';
|
||||||
if (subreddit.weight.trim() !== '' && Number(subreddit.weight) <= 0) {
|
if (subreddit.weight.trim() !== '' && Number(subreddit.weight) <= 0) {
|
||||||
@@ -140,6 +158,9 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
|
|||||||
prioritizeRecentPosts: draft.prioritizeRecentPosts,
|
prioritizeRecentPosts: draft.prioritizeRecentPosts,
|
||||||
recentWindowMinutes: Number(draft.recentWindowMinutes),
|
recentWindowMinutes: Number(draft.recentWindowMinutes),
|
||||||
maxReadyCandidates: Number(draft.maxReadyCandidates),
|
maxReadyCandidates: Number(draft.maxReadyCandidates),
|
||||||
|
physiqueTargetingEnabled: draft.physiqueTargetingEnabled,
|
||||||
|
physiqueMinScore: Number(draft.physiqueMinScore),
|
||||||
|
physiqueMaxScore: Number(draft.physiqueMaxScore),
|
||||||
subreddits: draft.subreddits.map((subreddit) => ({
|
subreddits: draft.subreddits.map((subreddit) => ({
|
||||||
name: subreddit.name.trim(),
|
name: subreddit.name.trim(),
|
||||||
enabled: subreddit.enabled,
|
enabled: subreddit.enabled,
|
||||||
@@ -185,6 +206,11 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
|
|||||||
prioritizeRecentPosts: parsed.data.prioritizeRecentPosts,
|
prioritizeRecentPosts: parsed.data.prioritizeRecentPosts,
|
||||||
recentWindowMinutes: parsed.data.recentWindowMinutes,
|
recentWindowMinutes: parsed.data.recentWindowMinutes,
|
||||||
maxReadyCandidates: parsed.data.maxReadyCandidates,
|
maxReadyCandidates: parsed.data.maxReadyCandidates,
|
||||||
|
physiqueTargeting: {
|
||||||
|
enabled: parsed.data.physiqueTargetingEnabled,
|
||||||
|
minScore: parsed.data.physiqueMinScore,
|
||||||
|
maxScore: parsed.data.physiqueMaxScore,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
subreddits: parsed.data.subreddits,
|
subreddits: parsed.data.subreddits,
|
||||||
...(advanced ? { advanced } : {}),
|
...(advanced ? { advanced } : {}),
|
||||||
@@ -217,7 +243,7 @@ export function isDraftDirty(draft: SettingsDraft, persisted: SettingsDraft): bo
|
|||||||
*/
|
*/
|
||||||
export function changedDraftFields(previous: SettingsDraft, next: SettingsDraft): string[] {
|
export function changedDraftFields(previous: SettingsDraft, next: SettingsDraft): string[] {
|
||||||
const changed: 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) {
|
for (const key of topLevelKeys) {
|
||||||
if (previous[key] !== next[key]) changed.push(key);
|
if (previous[key] !== next[key]) changed.push(key);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ interface NumberFieldProps {
|
|||||||
label: string;
|
label: string;
|
||||||
name: string;
|
name: string;
|
||||||
min?: number;
|
min?: number;
|
||||||
|
max?: number;
|
||||||
value: string;
|
value: string;
|
||||||
onChange(value: string): void;
|
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`;
|
const errorId = `${name}-error`;
|
||||||
return (
|
return (
|
||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
@@ -26,6 +27,7 @@ function NumberField({ error, label, name, min, value, onChange }: NumberFieldPr
|
|||||||
aria-invalid={error ? 'true' : undefined}
|
aria-invalid={error ? 'true' : undefined}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
min={min}
|
min={min}
|
||||||
|
max={max}
|
||||||
name={name}
|
name={name}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
step="1"
|
step="1"
|
||||||
@@ -66,6 +68,41 @@ export function MonitoringSection({ draft, errors, onChange }: SectionProps) {
|
|||||||
onChange={(maxReadyCandidates) => onChange({ ...draft, maxReadyCandidates })}
|
onChange={(maxReadyCandidates) => onChange({ ...draft, maxReadyCandidates })}
|
||||||
value={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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ reddit:
|
|||||||
prioritizeRecentPosts: true # Prefer posts within recentWindowMinutes; older posts only as fallback
|
prioritizeRecentPosts: true # Prefer posts within recentWindowMinutes; older posts only as fallback
|
||||||
recentWindowMinutes: 30
|
recentWindowMinutes: 30
|
||||||
maxReadyCandidates: 25 # Pause screening when this many posts are ready to send
|
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
|
# sellerKeywords: [...] # Uncomment to override default keyword list
|
||||||
|
|
||||||
conversations:
|
conversations:
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ an administrator credential.
|
|||||||
| `GET` | `/api/v1/jobs` | List recent asynchronous jobs | read | no |
|
| `GET` | `/api/v1/jobs` | List recent asynchronous jobs | read | no |
|
||||||
| `GET` | `/api/v1/jobs/{id}` | Get asynchronous job state | 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/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/stats` | Get bot statistics | read | no |
|
||||||
| `GET` | `/api/analytics/candidate-funnel` | Get durable candidate funnel counts | read | no |
|
| `GET` | `/api/analytics/candidate-funnel` | Get durable candidate funnel counts | read | no |
|
||||||
| `GET` | `/api/activity` | Get recent activity | read | no |
|
| `GET` | `/api/activity` | Get recent activity | read | no |
|
||||||
|
|||||||
@@ -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.
|
- `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.
|
- `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.
|
- `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.
|
Subreddit `type` is `image` or `chat`. Conversation policy contains `mode`, `maxAutomatedTurns`, and `rapportTurnsBeforeMediaAsk`; hybrid rapport turns must be lower than the maximum.
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -36,9 +36,9 @@ A screening worker leases one discovered candidate. `MonitorService.screenCandid
|
|||||||
3. Seller evidence from the post, profile/about, and recent activity.
|
3. Seller evidence from the post, profile/about, and recent activity.
|
||||||
4. Source-specific preparation in `ProcessorService`.
|
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
|
### 3. Dispatch
|
||||||
|
|
||||||
@@ -62,6 +62,8 @@ Do not add local retries around this layer without preserving the shared schedul
|
|||||||
|
|
||||||
## Reply-first conversation automation
|
## 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.
|
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.
|
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
@@ -32,7 +32,7 @@ The special legacy-unassigned account exists only to preserve rows created befor
|
|||||||
|
|
||||||
### Durable work and idempotency
|
### 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.
|
- `delivery_outbox`: account-scoped prepared delivery and idempotency status retained for legacy/recovery paths.
|
||||||
- `conversation_states`: account/conversation policy and derived state JSON.
|
- `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.
|
- `conversation_automation_claims`: account/conversation/inbound-event claim with intended reply and `pending`, `sent`, or terminal `superseded` state plus audit timestamps.
|
||||||
|
|||||||
@@ -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`.
|
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
|
## State model
|
||||||
|
|
||||||
- Remote state: TanStack Query snapshots parsed by shared Zod schemas.
|
- Remote state: TanStack Query snapshots parsed by shared Zod schemas.
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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
|
## 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.
|
- `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.
|
- `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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -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/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. |
|
| `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-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-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/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. |
|
| `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/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. |
|
| `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/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/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. |
|
| `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. |
|
| `utils/rate-limiter.ts` | Per-account send spacing/hour limits/jitter | Increment only for confirmed sends; reply priority may use the same limiter. |
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ export const RuntimeRedditConfigSchema = z.strictObject({
|
|||||||
prioritizeRecentPosts: z.boolean(),
|
prioritizeRecentPosts: z.boolean(),
|
||||||
recentWindowMinutes: z.number(),
|
recentWindowMinutes: z.number(),
|
||||||
maxReadyCandidates: z.number().int().min(1).max(500),
|
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({
|
export const RuntimeConfigSchema = z.strictObject({
|
||||||
rateLimit: RateLimitConfigSchema,
|
rateLimit: RateLimitConfigSchema,
|
||||||
|
|||||||
@@ -17,6 +17,27 @@ export const OutreachStageCountsSchema = z.strictObject({
|
|||||||
retryable: z.number().int().nonnegative(),
|
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({
|
export const OutreachCandidateSchema = z.strictObject({
|
||||||
postId: z.string(),
|
postId: z.string(),
|
||||||
subreddit: z.string(),
|
subreddit: z.string(),
|
||||||
@@ -32,6 +53,8 @@ export const OutreachCandidateSchema = z.strictObject({
|
|||||||
availableAt: z.number(),
|
availableAt: z.number(),
|
||||||
attemptCount: z.number().int().nonnegative(),
|
attemptCount: z.number().int().nonnegative(),
|
||||||
hasPreparedMessage: z.boolean(),
|
hasPreparedMessage: z.boolean(),
|
||||||
|
physiqueScore: z.number().int().min(1).max(10).nullable().default(null),
|
||||||
|
preparation: OutreachPreparationSchema.nullable().default(null),
|
||||||
note: z.string().nullable(),
|
note: z.string().nullable(),
|
||||||
owner: RedditAccountOwnerSchema.nullable(),
|
owner: RedditAccountOwnerSchema.nullable(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -149,7 +149,12 @@ describe('dashboard endpoint contracts', () => {
|
|||||||
const payload = {
|
const payload = {
|
||||||
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 },
|
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 },
|
||||||
browser: { dryRun: false },
|
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' }],
|
subreddits: [{ name: 'example', enabled: true, type: 'chat', weight: 2, tone: 'direct' }],
|
||||||
tones: ['default', 'direct'],
|
tones: ['default', 'direct'],
|
||||||
};
|
};
|
||||||
|
|||||||
+16
-2
@@ -30,6 +30,8 @@ export interface ImageAnalysisResult {
|
|||||||
description: string;
|
description: string;
|
||||||
detectedGender: DetectedGender;
|
detectedGender: DetectedGender;
|
||||||
attractivenessScore: number | null;
|
attractivenessScore: number | null;
|
||||||
|
/** Subjective 1-10 visible-physique score, or null when the image is insufficient. */
|
||||||
|
physiqueScore: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProtectedTextPrompt {
|
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 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
|
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:
|
Then provide your analysis:
|
||||||
|
|
||||||
${this.config.openrouter.imageAnalysisPrompt}`;
|
${this.config.openrouter.imageAnalysisPrompt}`;
|
||||||
@@ -411,6 +415,12 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
|
|||||||
detectedGender = genderMatch[1].toLowerCase() as DetectedGender;
|
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.
|
// Parse attractiveness rating (1-100 scale) from response.
|
||||||
// Tolerate markdown wrapping like **RATING:** 63 or RATING: **63**.
|
// Tolerate markdown wrapping like **RATING:** 63 or RATING: **63**.
|
||||||
let attractivenessScore: number | null = null;
|
let attractivenessScore: number | null = null;
|
||||||
@@ -425,6 +435,7 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
|
|||||||
// Remove gender and rating lines from description
|
// Remove gender and rating lines from description
|
||||||
const description = fullResponse
|
const description = fullResponse
|
||||||
.replace(/GENDER:\s*(male|female|other|unknown)\n?/i, '')
|
.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, '')
|
.replace(/\**\s*RATING[:\s*]*[*_\s]*\d{1,3}\**\n?/i, '')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
@@ -436,10 +447,13 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
|
|||||||
|
|
||||||
this.recordUsage('analyzeImage', model, startedAt, response, description ? 'success' : 'empty');
|
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}`);
|
this.logger.debug(`Description: ${description}`);
|
||||||
|
|
||||||
return { description, detectedGender, attractivenessScore };
|
return { description, detectedGender, attractivenessScore, physiqueScore };
|
||||||
}
|
}
|
||||||
|
|
||||||
private isRefusal(text: string): boolean {
|
private isRefusal(text: string): boolean {
|
||||||
|
|||||||
@@ -37,6 +37,22 @@ export const SubredditConfigSchema = z.object({
|
|||||||
|
|
||||||
export type SubredditConfig = z.infer<typeof SubredditConfigSchema>;
|
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
|
const SubredditEntrySchema = z
|
||||||
.union([z.string().transform((s) => ({ name: s })), z.object({}).passthrough()])
|
.union([z.string().transform((s) => ({ name: s })), z.object({}).passthrough()])
|
||||||
.pipe(SubredditConfigSchema);
|
.pipe(SubredditConfigSchema);
|
||||||
@@ -51,6 +67,7 @@ export const ConfigSchema = z.object({
|
|||||||
prioritizeRecentPosts: z.boolean().default(false),
|
prioritizeRecentPosts: z.boolean().default(false),
|
||||||
recentWindowMinutes: z.number().positive().default(60),
|
recentWindowMinutes: z.number().positive().default(60),
|
||||||
maxReadyCandidates: z.number().int().min(1).max(500).default(25),
|
maxReadyCandidates: z.number().int().min(1).max(500).default(25),
|
||||||
|
physiqueTargeting: PhysiqueTargetingSchema,
|
||||||
sellerKeywords: z
|
sellerKeywords: z
|
||||||
.array(z.string())
|
.array(z.string())
|
||||||
.default([
|
.default([
|
||||||
|
|||||||
@@ -134,6 +134,15 @@ export function createRedditBotMcpServer(client = new RedditBotApiClient()): Mcp
|
|||||||
{ message },
|
{ 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', {
|
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.',
|
description: 'Start a new Reddit conversation and send its first message. This has an external side effect and is not idempotent.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
|
|||||||
@@ -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
@@ -1,16 +1,21 @@
|
|||||||
import type { RedditApiClient, RedditPost } from '../reddit/api.js';
|
import type { RedditApiClient, RedditPost } from '../reddit/api.js';
|
||||||
import type { OpenRouterClient, DetectedGender } from '../ai/openrouter.js';
|
import type { OpenRouterClient, DetectedGender } from '../ai/openrouter.js';
|
||||||
import type { Logger } from '../utils/logger.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 type { StrategySelector, SelectedStrategy } from './strategy-selector.js';
|
||||||
import { downloadImage, bufferToBase64, isValidImageMimeType, type DownloadedImage } from '../utils/image.js';
|
import { downloadImage, bufferToBase64, isValidImageMimeType, type DownloadedImage } from '../utils/image.js';
|
||||||
import { parseGenderTag } from '../utils/gender-tag.js';
|
import { parseGenderTag } from '../utils/gender-tag.js';
|
||||||
import { resolveTone, type ToneProfile, type ToneKey } from '../config/tone-profiles.js';
|
import { resolveTone, type ToneProfile, type ToneKey } from '../config/tone-profiles.js';
|
||||||
|
import { evaluatePhysiqueTargeting } from './physique-targeting.js';
|
||||||
|
|
||||||
export interface ProcessedPost {
|
export interface ProcessedPost {
|
||||||
post: RedditPost;
|
post: RedditPost;
|
||||||
imageAnalysis: string;
|
imageAnalysis: string;
|
||||||
detectedGender: DetectedGender;
|
detectedGender: DetectedGender;
|
||||||
|
attractivenessScore: number | null;
|
||||||
|
physiqueScore: number | null;
|
||||||
|
physiqueTargeting: PhysiqueTargeting | null;
|
||||||
|
sourceImageUrl: string | null;
|
||||||
generatedMessage: string;
|
generatedMessage: string;
|
||||||
strategyId: string | null;
|
strategyId: string | null;
|
||||||
experimentId: string | null;
|
experimentId: string | null;
|
||||||
@@ -150,7 +155,28 @@ export class ProcessorService {
|
|||||||
this.logger.debug(`Gender check passed: ${detectedGender}`);
|
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;
|
let selectedStrategy: SelectedStrategy | null = null;
|
||||||
if (this.strategySelector) {
|
if (this.strategySelector) {
|
||||||
selectedStrategy = this.strategySelector.selectStrategy();
|
selectedStrategy = this.strategySelector.selectStrategy();
|
||||||
@@ -159,7 +185,6 @@ export class ProcessorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve subreddit-specific tone profile
|
|
||||||
const tone = this.resolveTone(post.subreddit);
|
const tone = this.resolveTone(post.subreddit);
|
||||||
if (tone.scripted) {
|
if (tone.scripted) {
|
||||||
this.logger.info(`Using scripted message for r/${post.subreddit}`);
|
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}`);
|
this.logger.info(`Using tone "${tone.profile.label}" for r/${post.subreddit}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full analysis and message generation (only for matching posts)
|
const strategyPrompt = tone.scripted
|
||||||
let analysis;
|
? tone.scripted
|
||||||
let message;
|
|
||||||
try {
|
|
||||||
if (tone.scripted) {
|
|
||||||
// Replace substitution tokens in the scripted prompt with post-specific data
|
|
||||||
let scriptedPrompt = tone.scripted
|
|
||||||
.replace('{title}', post.title)
|
.replace('{title}', post.title)
|
||||||
.replace('{author}', post.author)
|
.replace('{author}', post.author)
|
||||||
.replace('{flair}', post.link_flair_text || '(none)');
|
.replace('{flair}', post.link_flair_text || '(none)')
|
||||||
({ analysis, message } = await this.aiClient.analyzeAndGenerateMessage(
|
: selectedStrategy?.promptOverride;
|
||||||
base64,
|
const message = await this.aiClient.generateMessage(
|
||||||
imageData.mimeType,
|
analysis.description,
|
||||||
post.title,
|
post.title,
|
||||||
post.author,
|
post.author,
|
||||||
this.successfulExamples,
|
this.successfulExamples,
|
||||||
scriptedPrompt,
|
strategyPrompt,
|
||||||
post.link_flair_text ?? undefined,
|
analysis.attractivenessScore,
|
||||||
undefined,
|
post.link_flair_text ?? undefined,
|
||||||
this.recentMessageProvider('image'),
|
tone.scripted ? undefined : tone.profile.promptGuidance || 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!message || message.length < 10) {
|
if (!message || message.length < 10) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -220,6 +221,10 @@ export class ProcessorService {
|
|||||||
post,
|
post,
|
||||||
imageAnalysis: analysis.description,
|
imageAnalysis: analysis.description,
|
||||||
detectedGender: analysis.detectedGender,
|
detectedGender: analysis.detectedGender,
|
||||||
|
attractivenessScore: analysis.attractivenessScore,
|
||||||
|
physiqueScore: analysis.physiqueScore,
|
||||||
|
physiqueTargeting: { ...this.config.reddit.physiqueTargeting },
|
||||||
|
sourceImageUrl: imageUrl,
|
||||||
generatedMessage: message,
|
generatedMessage: message,
|
||||||
strategyId: selectedStrategy?.id || null,
|
strategyId: selectedStrategy?.id || null,
|
||||||
experimentId: selectedStrategy?.experimentId ?? null,
|
experimentId: selectedStrategy?.experimentId ?? null,
|
||||||
@@ -304,6 +309,10 @@ export class ProcessorService {
|
|||||||
post,
|
post,
|
||||||
imageAnalysis: `[chat] ${post.title}${post.selftext ? ` — ${post.selftext.slice(0, 200)}` : ''}`,
|
imageAnalysis: `[chat] ${post.title}${post.selftext ? ` — ${post.selftext.slice(0, 200)}` : ''}`,
|
||||||
detectedGender: detectedGender ?? 'unknown',
|
detectedGender: detectedGender ?? 'unknown',
|
||||||
|
attractivenessScore: null,
|
||||||
|
physiqueScore: null,
|
||||||
|
physiqueTargeting: null,
|
||||||
|
sourceImageUrl: null,
|
||||||
generatedMessage: message,
|
generatedMessage: message,
|
||||||
strategyId: selectedStrategy?.id || null,
|
strategyId: selectedStrategy?.id || null,
|
||||||
experimentId: selectedStrategy?.experimentId ?? null,
|
experimentId: selectedStrategy?.experimentId ?? null,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -94,6 +94,19 @@ export const ControlResponseSchema = z.strictObject({
|
|||||||
message: z.string(),
|
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', [
|
export const AgentJobRequestSchema = z.discriminatedUnion('type', [
|
||||||
z.strictObject({
|
z.strictObject({
|
||||||
type: z.literal('chat-sync'),
|
type: z.literal('chat-sync'),
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
HealthResponseSchema,
|
HealthResponseSchema,
|
||||||
OverviewResponseSchema,
|
OverviewResponseSchema,
|
||||||
ReadinessResponseSchema,
|
ReadinessResponseSchema,
|
||||||
|
SendRedditUserMessageRequestSchema,
|
||||||
|
SendRedditUserMessageResponseSchema,
|
||||||
} from './agent-api-contracts.js';
|
} from './agent-api-contracts.js';
|
||||||
|
|
||||||
export type ApiAccess = 'read' | 'control' | 'admin';
|
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('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('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('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('getStats', 'GET', '/api/stats', 'Get bot statistics', ['Dashboard']),
|
||||||
operation('getCandidateFunnel', 'GET', '/api/analytics/candidate-funnel', 'Get durable candidate funnel counts', ['Analytics']),
|
operation('getCandidateFunnel', 'GET', '/api/analytics/candidate-funnel', 'Get durable candidate funnel counts', ['Analytics']),
|
||||||
|
|||||||
+24
-1
@@ -1,9 +1,10 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import type { WebContext } from '../context.js';
|
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 { AgentJobManager } from '../agent-job-manager.js';
|
||||||
import { botControlStatus, setBotRunning } from '../bot-control.js';
|
import { botControlStatus, setBotRunning } from '../bot-control.js';
|
||||||
import { capabilities, createOpenApiDocument } from '../api-catalog.js';
|
import { capabilities, createOpenApiDocument } from '../api-catalog.js';
|
||||||
|
import { RedditUserMessageError, RedditUserMessenger } from '../../services/reddit-user-messenger.js';
|
||||||
|
|
||||||
export function createHealthRouter(startedAt = Date.now()): Router {
|
export function createHealthRouter(startedAt = Date.now()): Router {
|
||||||
const router = 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.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.get('/v1/control', (_req, res) => res.json(botControlStatus(ctx)));
|
||||||
router.post('/v1/control', (req, res) => {
|
router.post('/v1/control', (req, res) => {
|
||||||
const body = ControlRequestSchema.safeParse(req.body);
|
const body = ControlRequestSchema.safeParse(req.body);
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import type { WebContext } from '../context.js';
|
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 { TONE_KEYS } from '../../config/tone-profiles.js';
|
||||||
import { z } from 'zod';
|
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() });
|
res.status(400).json({ error: 'Invalid advanced configuration', detail: parsedAdvanced.error.format() });
|
||||||
return;
|
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 (updates.rateLimit) {
|
||||||
if (typeof updates.rateLimit.minDelayBetweenMessagesMs === 'number') {
|
if (typeof updates.rateLimit.minDelayBetweenMessagesMs === 'number') {
|
||||||
@@ -113,6 +128,9 @@ export function createConfigRouter(ctx: WebContext): Router {
|
|||||||
&& updates.reddit.maxReadyCandidates <= 500) {
|
&& updates.reddit.maxReadyCandidates <= 500) {
|
||||||
ctx.runtimeConfig.reddit.maxReadyCandidates = updates.reddit.maxReadyCandidates;
|
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.
|
// 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.prioritizeRecentPosts = ctx.runtimeConfig.reddit.prioritizeRecentPosts;
|
||||||
ctx.config.reddit.recentWindowMinutes = ctx.runtimeConfig.reddit.recentWindowMinutes;
|
ctx.config.reddit.recentWindowMinutes = ctx.runtimeConfig.reddit.recentWindowMinutes;
|
||||||
ctx.config.reddit.maxReadyCandidates = ctx.runtimeConfig.reddit.maxReadyCandidates;
|
ctx.config.reddit.maxReadyCandidates = ctx.runtimeConfig.reddit.maxReadyCandidates;
|
||||||
|
Object.assign(ctx.config.reddit.physiqueTargeting, ctx.runtimeConfig.reddit.physiqueTargeting);
|
||||||
|
|
||||||
if (parsedSubreddits?.success) {
|
if (parsedSubreddits?.success) {
|
||||||
const next: SubredditConfig[] = parsedSubreddits.data;
|
const next: SubredditConfig[] = parsedSubreddits.data;
|
||||||
|
|||||||
@@ -20,6 +20,23 @@ interface OutreachCandidateView {
|
|||||||
availableAt: number;
|
availableAt: number;
|
||||||
attemptCount: number;
|
attemptCount: number;
|
||||||
hasPreparedMessage: boolean;
|
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;
|
note: string | null;
|
||||||
owner: { accountId: string; redditUsername: string } | null;
|
owner: { accountId: string; redditUsername: string } | null;
|
||||||
}
|
}
|
||||||
@@ -61,6 +78,23 @@ export function createOutreachRouter(ctx: WebContext): Router {
|
|||||||
availableAt: candidate.availableAt,
|
availableAt: candidate.availableAt,
|
||||||
attemptCount: candidate.attemptCount,
|
attemptCount: candidate.attemptCount,
|
||||||
hasPreparedMessage: candidate.prepared !== null,
|
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,
|
note: candidate.lastError,
|
||||||
owner: candidate.assignedAccountId
|
owner: candidate.assignedAccountId
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export class WebServer {
|
|||||||
prioritizeRecentPosts: config.reddit.prioritizeRecentPosts,
|
prioritizeRecentPosts: config.reddit.prioritizeRecentPosts,
|
||||||
recentWindowMinutes: config.reddit.recentWindowMinutes,
|
recentWindowMinutes: config.reddit.recentWindowMinutes,
|
||||||
maxReadyCandidates: config.reddit.maxReadyCandidates,
|
maxReadyCandidates: config.reddit.maxReadyCandidates,
|
||||||
|
physiqueTargeting: { ...config.reddit.physiqueTargeting },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
this.app = express();
|
this.app = express();
|
||||||
|
|||||||
@@ -66,5 +66,10 @@ export interface RuntimeConfig {
|
|||||||
prioritizeRecentPosts: boolean;
|
prioritizeRecentPosts: boolean;
|
||||||
recentWindowMinutes: number;
|
recentWindowMinutes: number;
|
||||||
maxReadyCandidates: number;
|
maxReadyCandidates: number;
|
||||||
|
physiqueTargeting: {
|
||||||
|
enabled: boolean;
|
||||||
|
minScore: number;
|
||||||
|
maxScore: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ describe('agent API discovery', () => {
|
|||||||
expect(keys).toContain('POST /api/v1/control/start');
|
expect(keys).toContain('POST /api/v1/control/start');
|
||||||
expect(keys).toContain('POST /api/v1/control/stop');
|
expect(keys).toContain('POST /api/v1/control/stop');
|
||||||
expect(keys).toContain('POST /api/v1/jobs');
|
expect(keys).toContain('POST /api/v1/jobs');
|
||||||
|
expect(keys).toContain('POST /api/v1/messages');
|
||||||
expect(keys).toContain('GET /api/events/stream');
|
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/control/start'].post['x-mutates']).toBe(true);
|
||||||
expect(document.paths['/api/v1/jobs'].post.requestBody).toBeDefined();
|
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/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', () => {
|
it('returns a compact capability catalogue for agents', () => {
|
||||||
@@ -48,5 +50,9 @@ describe('agent API discovery', () => {
|
|||||||
access: 'admin',
|
access: 'admin',
|
||||||
mutates: true,
|
mutates: true,
|
||||||
});
|
});
|
||||||
|
expect(result.operations.find((item) => item.operationId === 'sendRedditUserMessage')).toMatchObject({
|
||||||
|
access: 'admin',
|
||||||
|
mutates: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -31,6 +31,10 @@ function prepared(candidate: RedditPost): ProcessedPost {
|
|||||||
post: candidate,
|
post: candidate,
|
||||||
imageAnalysis: 'analysis',
|
imageAnalysis: 'analysis',
|
||||||
detectedGender: 'female',
|
detectedGender: 'female',
|
||||||
|
attractivenessScore: 64,
|
||||||
|
physiqueScore: 5,
|
||||||
|
physiqueTargeting: { enabled: true, minScore: 3, maxScore: 7 },
|
||||||
|
sourceImageUrl: 'https://i.redd.it/example.jpg',
|
||||||
generatedMessage: 'prepared message',
|
generatedMessage: 'prepared message',
|
||||||
strategyId: null,
|
strategyId: null,
|
||||||
experimentId: null,
|
experimentId: null,
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ describe('dashboard foundation', () => {
|
|||||||
|
|
||||||
it('shows the live outreach stages and the potential posts being burned down', async () => {
|
it('shows the live outreach stages and the potential posts being burned down', async () => {
|
||||||
installSuccessfulApi();
|
installSuccessfulApi();
|
||||||
|
const user = userEvent.setup();
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: true, status: 'authenticated' })),
|
http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: true, status: 'authenticated' })),
|
||||||
http.get('/api/chats/sync', () => HttpResponse.json({
|
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'),
|
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'),
|
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,
|
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,
|
note: null, owner: null,
|
||||||
}],
|
}],
|
||||||
})),
|
})),
|
||||||
@@ -141,11 +153,21 @@ describe('dashboard foundation', () => {
|
|||||||
expect(await screen.findByRole('heading', { name: 'Outreach funnel' })).toBeInTheDocument();
|
expect(await screen.findByRole('heading', { name: 'Outreach funnel' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('heading', { name: '3 active posts' })).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('list', { name: 'Active outreach candidates' })).toHaveTextContent('A potential post');
|
||||||
expect(screen.getByRole('link', { name: '[25F] A potential post' })).toHaveAttribute(
|
expect(screen.getAllByRole('link', { name: '[25F] A potential post' }))
|
||||||
'href',
|
.toEqual(expect.arrayContaining([
|
||||||
'https://www.reddit.com/r/example/comments/candidate-1/',
|
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();
|
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 () => {
|
it('hydrates media filters from the routed URL', async () => {
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ import { SettingsRoute } from '../../apps/dashboard/src/features/settings/settin
|
|||||||
const config = {
|
const config = {
|
||||||
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 },
|
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 },
|
||||||
browser: { dryRun: true },
|
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' }],
|
subreddits: [{ name: 'casualconversation', type: 'chat', weight: 2, tone: 'default' }],
|
||||||
tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'],
|
tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'],
|
||||||
advanced: { openrouter: { chatModel: 'example/chat-model' } },
|
advanced: { openrouter: { chatModel: 'example/chat-model' } },
|
||||||
@@ -204,6 +209,46 @@ describe('settings and access', () => {
|
|||||||
expect(screen.queryByText('Settings saved.')).not.toBeInTheDocument();
|
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 () => {
|
it('validates subreddit rows and confirms disabling dry run before saving behaviour', async () => {
|
||||||
installConfigApi();
|
installConfigApi();
|
||||||
let updateRequests = 0;
|
let updateRequests = 0;
|
||||||
|
|||||||
@@ -29,10 +29,11 @@ describe('RedditBot MCP server', () => {
|
|||||||
|
|
||||||
expect(names).toEqual(expect.arrayContaining([
|
expect(names).toEqual(expect.arrayContaining([
|
||||||
'get_overview', 'list_accounts', 'list_conversations', 'start_bot', 'stop_bot',
|
'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 === '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_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 () => {
|
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(1, '/api/v1/control/start');
|
||||||
expect(post).toHaveBeenNthCalledWith(2, '/api/v1/control/stop');
|
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' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -315,6 +315,26 @@ describe('OpenRouterClient', () => {
|
|||||||
expect(result.attractivenessScore).toBeNull();
|
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 () => {
|
it('handles case-insensitive GENDER prefix', async () => {
|
||||||
mockCreate.mockResolvedValueOnce({
|
mockCreate.mockResolvedValueOnce({
|
||||||
choices: [{ message: { content: 'GENDER:FEMALE\n\nA woman.' } }],
|
choices: [{ message: { content: 'GENDER:FEMALE\n\nA woman.' } }],
|
||||||
|
|||||||
+75
-19
@@ -24,13 +24,18 @@ function createMockRedditApi(imageUrl: string | null = 'http://example.com/img.j
|
|||||||
|
|
||||||
function createMockAiClient(
|
function createMockAiClient(
|
||||||
gender: DetectedGender = 'female',
|
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!'
|
message: string = 'Hey gorgeous!'
|
||||||
): OpenRouterClient {
|
): OpenRouterClient {
|
||||||
return {
|
return {
|
||||||
detectGenderOnly: vi.fn(async () => gender),
|
detectGenderOnly: vi.fn(async () => gender),
|
||||||
analyzeImage: vi.fn(async () => analysis),
|
analyzeImage: vi.fn(async () => analysis),
|
||||||
generateMessage: vi.fn(async (analysis: string) => message),
|
generateMessage: vi.fn(async () => message),
|
||||||
generateReply: vi.fn(async () => 'Reply'),
|
generateReply: vi.fn(async () => 'Reply'),
|
||||||
detectUpsell: vi.fn(() => ({ isUpsell: false })),
|
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) => {
|
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);
|
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 () => {
|
it('skips when AI refuses', async () => {
|
||||||
const error = new Error('AI_REFUSAL: All models refused to analyze image');
|
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);
|
const result = await processor.processPost(mockPost);
|
||||||
expect(isSkip(result)).toBe(true);
|
expect(isSkip(result)).toBe(true);
|
||||||
@@ -134,14 +190,14 @@ describe('ProcessorService', () => {
|
|||||||
|
|
||||||
it('re-throws non-AI_REFUSAL errors', async () => {
|
it('re-throws non-AI_REFUSAL errors', async () => {
|
||||||
const error = new Error('Network error');
|
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');
|
await expect(processor.processPost(mockPost)).rejects.toThrow('Network error');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips when generated message is too short', async () => {
|
it('skips when generated message is too short', async () => {
|
||||||
aiClient = createMockAiClient('female',
|
aiClient = createMockAiClient('female',
|
||||||
{ description: 'Nice', detectedGender: 'female' },
|
{ description: 'Nice', detectedGender: 'female', attractivenessScore: null, physiqueScore: null },
|
||||||
'Short'
|
'Short'
|
||||||
);
|
);
|
||||||
const config = getTestConfig();
|
const config = getTestConfig();
|
||||||
@@ -154,7 +210,7 @@ describe('ProcessorService', () => {
|
|||||||
|
|
||||||
it('skips when generated message is empty', async () => {
|
it('skips when generated message is empty', async () => {
|
||||||
aiClient = createMockAiClient('female',
|
aiClient = createMockAiClient('female',
|
||||||
{ description: 'Nice', detectedGender: 'female' },
|
{ description: 'Nice', detectedGender: 'female', attractivenessScore: null, physiqueScore: null },
|
||||||
''
|
''
|
||||||
);
|
);
|
||||||
const config = getTestConfig();
|
const config = getTestConfig();
|
||||||
@@ -188,13 +244,13 @@ describe('ProcessorService', () => {
|
|||||||
|
|
||||||
await processor.processPost(mockPost);
|
await processor.processPost(mockPost);
|
||||||
|
|
||||||
expect(aiClient.analyzeAndGenerateMessage).toHaveBeenCalledWith(
|
expect(aiClient.generateMessage).toHaveBeenCalledWith(
|
||||||
expect.any(String), // base64
|
'Beautiful woman',
|
||||||
'image/jpeg',
|
|
||||||
'Test Post',
|
'Test Post',
|
||||||
'testuser',
|
'testuser',
|
||||||
['Example 1: Hey', 'Example 2: Nice'],
|
['Example 1: Hey', 'Example 2: Nice'],
|
||||||
undefined,
|
undefined,
|
||||||
|
null,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
[],
|
[],
|
||||||
@@ -211,13 +267,13 @@ describe('ProcessorService', () => {
|
|||||||
|
|
||||||
await processor.processPost(mockPost);
|
await processor.processPost(mockPost);
|
||||||
|
|
||||||
expect(aiClient.analyzeAndGenerateMessage).toHaveBeenCalledWith(
|
expect(aiClient.generateMessage).toHaveBeenCalledWith(
|
||||||
expect.any(String),
|
'Beautiful woman',
|
||||||
'image/jpeg',
|
|
||||||
'Test Post',
|
'Test Post',
|
||||||
'testuser',
|
'testuser',
|
||||||
[],
|
[],
|
||||||
'Be teasing',
|
'Be teasing',
|
||||||
|
null,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
[],
|
[],
|
||||||
@@ -238,13 +294,13 @@ describe('ProcessorService', () => {
|
|||||||
|
|
||||||
await processor.processPost(mockPost);
|
await processor.processPost(mockPost);
|
||||||
|
|
||||||
expect(aiClient.analyzeAndGenerateMessage).toHaveBeenCalledWith(
|
expect(aiClient.generateMessage).toHaveBeenCalledWith(
|
||||||
expect.any(String),
|
'Beautiful woman',
|
||||||
'image/jpeg',
|
|
||||||
'Test Post',
|
'Test Post',
|
||||||
'testuser',
|
'testuser',
|
||||||
[],
|
[],
|
||||||
undefined,
|
undefined,
|
||||||
|
null,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
recentMessages,
|
recentMessages,
|
||||||
@@ -281,13 +337,13 @@ describe('ProcessorService', () => {
|
|||||||
|
|
||||||
await processor.processPost(mockPost);
|
await processor.processPost(mockPost);
|
||||||
|
|
||||||
expect(aiClient.analyzeAndGenerateMessage).toHaveBeenCalledWith(
|
expect(aiClient.generateMessage).toHaveBeenCalledWith(
|
||||||
expect.any(String),
|
'Beautiful woman',
|
||||||
'image/jpeg',
|
|
||||||
'Test Post',
|
'Test Post',
|
||||||
'testuser',
|
'testuser',
|
||||||
['Example A'],
|
['Example A'],
|
||||||
undefined,
|
undefined,
|
||||||
|
null,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
[],
|
[],
|
||||||
@@ -428,7 +484,7 @@ describe('ProcessorService', () => {
|
|||||||
await processor.processPost(tonedPost);
|
await processor.processPost(tonedPost);
|
||||||
|
|
||||||
// 8th arg (index 7) after strategyPrompt and postFlair is toneGuidance
|
// 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('STYLE TONE');
|
||||||
expect(callArgs[7]).toContain('commanding');
|
expect(callArgs[7]).toContain('commanding');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ describe('materializePreparedPostForAccount', () => {
|
|||||||
thumbnail: '', is_self: false, created_utc: 123, over_18: true, spoiler: false,
|
thumbnail: '', is_self: false, created_utc: 123, over_18: true, spoiler: false,
|
||||||
};
|
};
|
||||||
const processed: ProcessedPost = {
|
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,
|
strategyId: null, experimentId: null, promptVersion: null, selectionReason: null,
|
||||||
modelId: 'model', sourceType: 'image', toneId: 'default', scripted: false,
|
modelId: 'model', sourceType: 'image', toneId: 'default', scripted: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,6 +44,7 @@ export function getTestConfig(overrides?: Partial<Config>): Config {
|
|||||||
prioritizeRecentPosts: false,
|
prioritizeRecentPosts: false,
|
||||||
recentWindowMinutes: 60,
|
recentWindowMinutes: 60,
|
||||||
maxReadyCandidates: 25,
|
maxReadyCandidates: 25,
|
||||||
|
physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 },
|
||||||
...overrides?.reddit,
|
...overrides?.reddit,
|
||||||
},
|
},
|
||||||
openrouter: {
|
openrouter: {
|
||||||
|
|||||||
@@ -109,3 +109,43 @@ describe('config route conversation kill controls', () => {
|
|||||||
expect(config.conversations.aiReplyScreening.enabled).toBe(false);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,7 +20,7 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('outreach route', () => {
|
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 = {
|
const candidateQueue = {
|
||||||
getStats: vi.fn(() => ({
|
getStats: vi.fn(() => ({
|
||||||
total: 9,
|
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/',
|
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,
|
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,
|
discoveredAt: 1_000, updatedAt: 2_000, availableAt: 2_000,
|
||||||
leaseOwner: 'worker-secret', leaseExpiresAt: 3_000, assignedAccountId: 'account-a',
|
leaseOwner: 'worker-secret', leaseExpiresAt: 3_000, assignedAccountId: 'account-a',
|
||||||
attemptCount: 2, lastError: null,
|
attemptCount: 2, lastError: null,
|
||||||
@@ -64,9 +79,16 @@ describe('outreach route', () => {
|
|||||||
postId: 'post-1', status: 'sending', hasPreparedMessage: true,
|
postId: 'post-1', status: 'sending', hasPreparedMessage: true,
|
||||||
postUrl: 'https://www.reddit.com/r/example/comments/post-1/hello/',
|
postUrl: 'https://www.reddit.com/r/example/comments/post-1/hello/',
|
||||||
owner: { accountId: 'account-a', redditUsername: 'operator' },
|
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');
|
expect(JSON.stringify(body)).not.toContain('worker-secret');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user