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
+16
-2
@@ -30,6 +30,8 @@ export interface ImageAnalysisResult {
|
||||
description: string;
|
||||
detectedGender: DetectedGender;
|
||||
attractivenessScore: number | null;
|
||||
/** Subjective 1-10 visible-physique score, or null when the image is insufficient. */
|
||||
physiqueScore: number | null;
|
||||
}
|
||||
|
||||
interface ProtectedTextPrompt {
|
||||
@@ -370,6 +372,8 @@ Use every applicable tag; tags may overlap. Set safety to "nsfw" when nudity, ex
|
||||
const startedAt = Date.now();
|
||||
const genderPrompt = `First, identify the gender of the person in this image. Respond with EXACTLY one of these on the first line: GENDER:male, GENDER:female, GENDER:other, or GENDER:unknown
|
||||
|
||||
On the second line, assess only the directly visible physique. Output exactly PHYSIQUE_SCORE:<number> using an integer from 1 to 10, or PHYSIQUE_SCORE:UNKNOWN when the torso and enough of the body are not visible for a grounded assessment. Do not infer physique from a face, a close crop, clothing that obscures body shape, or off-frame anatomy. This is not a medical diagnosis: 1 means a visibly very large-bodied physique, 5 means a broadly average physique, and 10 means an exceptionally lean, toned, model-like physique. Ignore facial attractiveness, photography quality, pose, and setting.
|
||||
|
||||
Then provide your analysis:
|
||||
|
||||
${this.config.openrouter.imageAnalysisPrompt}`;
|
||||
@@ -411,6 +415,12 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
|
||||
detectedGender = genderMatch[1].toLowerCase() as DetectedGender;
|
||||
}
|
||||
|
||||
let physiqueScore: number | null = null;
|
||||
const physiqueMatch = fullResponse.match(/PHYSIQUE_SCORE[:\s*_]*[*_\s]*(UNKNOWN|10|[1-9])\b/i);
|
||||
if (physiqueMatch && physiqueMatch[1].toUpperCase() !== 'UNKNOWN') {
|
||||
physiqueScore = Number(physiqueMatch[1]);
|
||||
}
|
||||
|
||||
// Parse attractiveness rating (1-100 scale) from response.
|
||||
// Tolerate markdown wrapping like **RATING:** 63 or RATING: **63**.
|
||||
let attractivenessScore: number | null = null;
|
||||
@@ -425,6 +435,7 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
|
||||
// Remove gender and rating lines from description
|
||||
const description = fullResponse
|
||||
.replace(/GENDER:\s*(male|female|other|unknown)\n?/i, '')
|
||||
.replace(/\**\s*PHYSIQUE_SCORE[:\s*_]*[*_\s]*(?:UNKNOWN|10|[1-9])\**\n?/i, '')
|
||||
.replace(/\**\s*RATING[:\s*]*[*_\s]*\d{1,3}\**\n?/i, '')
|
||||
.trim();
|
||||
|
||||
@@ -436,10 +447,13 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
|
||||
|
||||
this.recordUsage('analyzeImage', model, startedAt, response, description ? 'success' : 'empty');
|
||||
|
||||
this.logger.debug(`Image analysis complete (${model}) - Gender: ${detectedGender}, Rating: ${attractivenessScore ?? 'n/a'}`);
|
||||
this.logger.debug(
|
||||
`Image analysis complete (${model}) - Gender: ${detectedGender}, `
|
||||
+ `Physique: ${physiqueScore ?? 'n/a'}, Rating: ${attractivenessScore ?? 'n/a'}`,
|
||||
);
|
||||
this.logger.debug(`Description: ${description}`);
|
||||
|
||||
return { description, detectedGender, attractivenessScore };
|
||||
return { description, detectedGender, attractivenessScore, physiqueScore };
|
||||
}
|
||||
|
||||
private isRefusal(text: string): boolean {
|
||||
|
||||
@@ -37,6 +37,22 @@ export const SubredditConfigSchema = z.object({
|
||||
|
||||
export type SubredditConfig = z.infer<typeof SubredditConfigSchema>;
|
||||
|
||||
export const PhysiqueTargetingSchema = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
minScore: z.number().int().min(1).max(10).default(1),
|
||||
maxScore: z.number().int().min(1).max(10).default(10),
|
||||
}).superRefine((targeting, context) => {
|
||||
if (targeting.minScore > targeting.maxScore) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['minScore'],
|
||||
message: 'Minimum physique score cannot exceed maximum physique score.',
|
||||
});
|
||||
}
|
||||
}).default({ enabled: false, minScore: 1, maxScore: 10 });
|
||||
|
||||
export type PhysiqueTargeting = z.infer<typeof PhysiqueTargetingSchema>;
|
||||
|
||||
const SubredditEntrySchema = z
|
||||
.union([z.string().transform((s) => ({ name: s })), z.object({}).passthrough()])
|
||||
.pipe(SubredditConfigSchema);
|
||||
@@ -51,6 +67,7 @@ export const ConfigSchema = z.object({
|
||||
prioritizeRecentPosts: z.boolean().default(false),
|
||||
recentWindowMinutes: z.number().positive().default(60),
|
||||
maxReadyCandidates: z.number().int().min(1).max(500).default(25),
|
||||
physiqueTargeting: PhysiqueTargetingSchema,
|
||||
sellerKeywords: z
|
||||
.array(z.string())
|
||||
.default([
|
||||
|
||||
@@ -134,6 +134,15 @@ export function createRedditBotMcpServer(client = new RedditBotApiClient()): Mcp
|
||||
{ message },
|
||||
)));
|
||||
|
||||
server.registerTool('send_message_to_user', {
|
||||
description: 'Send a Reddit message with automatic account selection. An account with an existing conversation with the user is preferred.',
|
||||
inputSchema: {
|
||||
username: z.string().min(1).max(64),
|
||||
message: z.string().min(1).max(10_000),
|
||||
},
|
||||
annotations: sendAnnotations,
|
||||
}, async (input) => result(await client.post('/api/v1/messages', input)));
|
||||
|
||||
server.registerTool('start_conversation', {
|
||||
description: 'Start a new Reddit conversation and send its first message. This has an external side effect and is not idempotent.',
|
||||
inputSchema: {
|
||||
|
||||
@@ -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 { OpenRouterClient, DetectedGender } from '../ai/openrouter.js';
|
||||
import type { Logger } from '../utils/logger.js';
|
||||
import type { Config } from '../config/index.js';
|
||||
import type { Config, PhysiqueTargeting } from '../config/index.js';
|
||||
import type { StrategySelector, SelectedStrategy } from './strategy-selector.js';
|
||||
import { downloadImage, bufferToBase64, isValidImageMimeType, type DownloadedImage } from '../utils/image.js';
|
||||
import { parseGenderTag } from '../utils/gender-tag.js';
|
||||
import { resolveTone, type ToneProfile, type ToneKey } from '../config/tone-profiles.js';
|
||||
import { evaluatePhysiqueTargeting } from './physique-targeting.js';
|
||||
|
||||
export interface ProcessedPost {
|
||||
post: RedditPost;
|
||||
imageAnalysis: string;
|
||||
detectedGender: DetectedGender;
|
||||
attractivenessScore: number | null;
|
||||
physiqueScore: number | null;
|
||||
physiqueTargeting: PhysiqueTargeting | null;
|
||||
sourceImageUrl: string | null;
|
||||
generatedMessage: string;
|
||||
strategyId: string | null;
|
||||
experimentId: string | null;
|
||||
@@ -150,7 +155,28 @@ export class ProcessorService {
|
||||
this.logger.debug(`Gender check passed: ${detectedGender}`);
|
||||
}
|
||||
|
||||
// Select a strategy for A/B testing
|
||||
// Analyze before strategy selection and opener generation so a rejected
|
||||
// physique score costs only the analysis request.
|
||||
let analysis;
|
||||
try {
|
||||
analysis = await this.aiClient.analyzeImage(base64, imageData.mimeType);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('AI_REFUSAL')) {
|
||||
this.logger.warn(`AI refused to analyze post ${post.id} by u/${post.author} - skipping`);
|
||||
return { skipReason: 'AI refused to analyze image' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const physiqueDecision = evaluatePhysiqueTargeting(
|
||||
analysis.physiqueScore,
|
||||
this.config.reddit.physiqueTargeting,
|
||||
);
|
||||
if (!physiqueDecision.accepted) {
|
||||
this.logger.info(`Skipping post ${post.id} - ${physiqueDecision.reason}`);
|
||||
return { skipReason: physiqueDecision.reason };
|
||||
}
|
||||
|
||||
let selectedStrategy: SelectedStrategy | null = null;
|
||||
if (this.strategySelector) {
|
||||
selectedStrategy = this.strategySelector.selectStrategy();
|
||||
@@ -159,7 +185,6 @@ export class ProcessorService {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve subreddit-specific tone profile
|
||||
const tone = this.resolveTone(post.subreddit);
|
||||
if (tone.scripted) {
|
||||
this.logger.info(`Using scripted message for r/${post.subreddit}`);
|
||||
@@ -167,47 +192,23 @@ export class ProcessorService {
|
||||
this.logger.info(`Using tone "${tone.profile.label}" for r/${post.subreddit}`);
|
||||
}
|
||||
|
||||
// Full analysis and message generation (only for matching posts)
|
||||
let analysis;
|
||||
let message;
|
||||
try {
|
||||
if (tone.scripted) {
|
||||
// Replace substitution tokens in the scripted prompt with post-specific data
|
||||
let scriptedPrompt = tone.scripted
|
||||
const strategyPrompt = tone.scripted
|
||||
? tone.scripted
|
||||
.replace('{title}', post.title)
|
||||
.replace('{author}', post.author)
|
||||
.replace('{flair}', post.link_flair_text || '(none)');
|
||||
({ analysis, message } = await this.aiClient.analyzeAndGenerateMessage(
|
||||
base64,
|
||||
imageData.mimeType,
|
||||
post.title,
|
||||
post.author,
|
||||
this.successfulExamples,
|
||||
scriptedPrompt,
|
||||
post.link_flair_text ?? undefined,
|
||||
undefined,
|
||||
this.recentMessageProvider('image'),
|
||||
));
|
||||
} else {
|
||||
({ analysis, message } = await this.aiClient.analyzeAndGenerateMessage(
|
||||
base64,
|
||||
imageData.mimeType,
|
||||
post.title,
|
||||
post.author,
|
||||
this.successfulExamples,
|
||||
selectedStrategy?.promptOverride,
|
||||
post.link_flair_text ?? undefined,
|
||||
tone.profile.promptGuidance || undefined,
|
||||
this.recentMessageProvider('image'),
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('AI_REFUSAL')) {
|
||||
this.logger.warn(`AI refused to analyze post ${post.id} by u/${post.author} - skipping`);
|
||||
return { skipReason: 'AI refused to analyze image' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
.replace('{flair}', post.link_flair_text || '(none)')
|
||||
: selectedStrategy?.promptOverride;
|
||||
const message = await this.aiClient.generateMessage(
|
||||
analysis.description,
|
||||
post.title,
|
||||
post.author,
|
||||
this.successfulExamples,
|
||||
strategyPrompt,
|
||||
analysis.attractivenessScore,
|
||||
post.link_flair_text ?? undefined,
|
||||
tone.scripted ? undefined : tone.profile.promptGuidance || undefined,
|
||||
this.recentMessageProvider('image'),
|
||||
);
|
||||
|
||||
if (!message || message.length < 10) {
|
||||
this.logger.warn(
|
||||
@@ -220,6 +221,10 @@ export class ProcessorService {
|
||||
post,
|
||||
imageAnalysis: analysis.description,
|
||||
detectedGender: analysis.detectedGender,
|
||||
attractivenessScore: analysis.attractivenessScore,
|
||||
physiqueScore: analysis.physiqueScore,
|
||||
physiqueTargeting: { ...this.config.reddit.physiqueTargeting },
|
||||
sourceImageUrl: imageUrl,
|
||||
generatedMessage: message,
|
||||
strategyId: selectedStrategy?.id || null,
|
||||
experimentId: selectedStrategy?.experimentId ?? null,
|
||||
@@ -304,6 +309,10 @@ export class ProcessorService {
|
||||
post,
|
||||
imageAnalysis: `[chat] ${post.title}${post.selftext ? ` — ${post.selftext.slice(0, 200)}` : ''}`,
|
||||
detectedGender: detectedGender ?? 'unknown',
|
||||
attractivenessScore: null,
|
||||
physiqueScore: null,
|
||||
physiqueTargeting: null,
|
||||
sourceImageUrl: null,
|
||||
generatedMessage: message,
|
||||
strategyId: selectedStrategy?.id || null,
|
||||
experimentId: selectedStrategy?.experimentId ?? null,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
export const SendRedditUserMessageRequestSchema = z.strictObject({
|
||||
username: z.string().trim().min(1).max(64),
|
||||
message: z.string().trim().min(1).max(10_000),
|
||||
});
|
||||
|
||||
export const SendRedditUserMessageResponseSchema = z.strictObject({
|
||||
accountId: z.string(),
|
||||
redditUsername: z.string(),
|
||||
recipient: z.string(),
|
||||
conversationId: z.string(),
|
||||
reusedConversation: z.boolean(),
|
||||
});
|
||||
|
||||
export const AgentJobRequestSchema = z.discriminatedUnion('type', [
|
||||
z.strictObject({
|
||||
type: z.literal('chat-sync'),
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
HealthResponseSchema,
|
||||
OverviewResponseSchema,
|
||||
ReadinessResponseSchema,
|
||||
SendRedditUserMessageRequestSchema,
|
||||
SendRedditUserMessageResponseSchema,
|
||||
} from './agent-api-contracts.js';
|
||||
|
||||
export type ApiAccess = 'read' | 'control' | 'admin';
|
||||
@@ -66,6 +68,7 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
|
||||
operation('listJobs', 'GET', '/api/v1/jobs', 'List recent asynchronous jobs', ['Jobs'], { responseSchema: z.array(AgentJobSchema) }),
|
||||
operation('getJob', 'GET', '/api/v1/jobs/{id}', 'Get asynchronous job state', ['Jobs'], { responseSchema: AgentJobSchema }),
|
||||
operation('createJob', 'POST', '/api/v1/jobs', 'Start chat or media synchronisation', ['Jobs'], { access: 'control', requestSchema: AgentJobRequestSchema, responseSchema: AgentJobSchema, successStatus: 202 }),
|
||||
operation('sendRedditUserMessage', 'POST', '/api/v1/messages', 'Send a message to a Reddit user with automatic account selection', ['Agent', 'Conversations'], { access: 'admin', requestSchema: SendRedditUserMessageRequestSchema, responseSchema: SendRedditUserMessageResponseSchema }),
|
||||
|
||||
operation('getStats', 'GET', '/api/stats', 'Get bot statistics', ['Dashboard']),
|
||||
operation('getCandidateFunnel', 'GET', '/api/analytics/candidate-funnel', 'Get durable candidate funnel counts', ['Analytics']),
|
||||
|
||||
+24
-1
@@ -1,9 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import type { WebContext } from '../context.js';
|
||||
import { AgentJobRequestSchema, ControlRequestSchema } from '../agent-api-contracts.js';
|
||||
import { AgentJobRequestSchema, ControlRequestSchema, SendRedditUserMessageRequestSchema } from '../agent-api-contracts.js';
|
||||
import { AgentJobManager } from '../agent-job-manager.js';
|
||||
import { botControlStatus, setBotRunning } from '../bot-control.js';
|
||||
import { capabilities, createOpenApiDocument } from '../api-catalog.js';
|
||||
import { RedditUserMessageError, RedditUserMessenger } from '../../services/reddit-user-messenger.js';
|
||||
|
||||
export function createHealthRouter(startedAt = Date.now()): Router {
|
||||
const router = Router();
|
||||
@@ -30,6 +31,28 @@ export function createAgentRouter(ctx: WebContext): Router {
|
||||
});
|
||||
router.get('/v1/overview', (_req, res) => res.json(buildOverview(ctx)));
|
||||
|
||||
router.post('/v1/messages', async (req, res) => {
|
||||
const body = SendRedditUserMessageRequestSchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
res.status(400).json({ error: 'Invalid message request', detail: body.error.format() });
|
||||
return;
|
||||
}
|
||||
if (!ctx.services.accountRuntimes) {
|
||||
res.status(503).json({ error: 'Reddit account runtimes are unavailable' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await new RedditUserMessenger(ctx.services.accountRuntimes)
|
||||
.send(body.data.username, body.data.message);
|
||||
ctx.broadcaster.push('info', `API message sent to u/${result.recipient} via u/${result.redditUsername}`);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
ctx.logger.error('Failed to send Reddit user message:', error);
|
||||
const status = error instanceof RedditUserMessageError ? error.status : 500;
|
||||
res.status(status).json({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/v1/control', (_req, res) => res.json(botControlStatus(ctx)));
|
||||
router.post('/v1/control', (req, res) => {
|
||||
const body = ControlRequestSchema.safeParse(req.body);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import type { WebContext } from '../context.js';
|
||||
import { ConfigSchema, SubredditConfigSchema, type SubredditConfig } from '../../config/index.js';
|
||||
import {
|
||||
ConfigSchema,
|
||||
PhysiqueTargetingSchema,
|
||||
SubredditConfigSchema,
|
||||
type SubredditConfig,
|
||||
} from '../../config/index.js';
|
||||
import { TONE_KEYS } from '../../config/tone-profiles.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -75,6 +80,16 @@ export function createConfigRouter(ctx: WebContext): Router {
|
||||
res.status(400).json({ error: 'Invalid advanced configuration', detail: parsedAdvanced.error.format() });
|
||||
return;
|
||||
}
|
||||
const parsedPhysiqueTargeting = updates.reddit?.physiqueTargeting === undefined
|
||||
? null
|
||||
: PhysiqueTargetingSchema.safeParse(updates.reddit.physiqueTargeting);
|
||||
if (parsedPhysiqueTargeting && !parsedPhysiqueTargeting.success) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid physique targeting configuration',
|
||||
detail: parsedPhysiqueTargeting.error.format(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (updates.rateLimit) {
|
||||
if (typeof updates.rateLimit.minDelayBetweenMessagesMs === 'number') {
|
||||
@@ -113,6 +128,9 @@ export function createConfigRouter(ctx: WebContext): Router {
|
||||
&& updates.reddit.maxReadyCandidates <= 500) {
|
||||
ctx.runtimeConfig.reddit.maxReadyCandidates = updates.reddit.maxReadyCandidates;
|
||||
}
|
||||
if (parsedPhysiqueTargeting?.success) {
|
||||
Object.assign(ctx.runtimeConfig.reddit.physiqueTargeting, parsedPhysiqueTargeting.data);
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror into the loaded config so getStats reflects new values.
|
||||
@@ -123,6 +141,7 @@ export function createConfigRouter(ctx: WebContext): Router {
|
||||
ctx.config.reddit.prioritizeRecentPosts = ctx.runtimeConfig.reddit.prioritizeRecentPosts;
|
||||
ctx.config.reddit.recentWindowMinutes = ctx.runtimeConfig.reddit.recentWindowMinutes;
|
||||
ctx.config.reddit.maxReadyCandidates = ctx.runtimeConfig.reddit.maxReadyCandidates;
|
||||
Object.assign(ctx.config.reddit.physiqueTargeting, ctx.runtimeConfig.reddit.physiqueTargeting);
|
||||
|
||||
if (parsedSubreddits?.success) {
|
||||
const next: SubredditConfig[] = parsedSubreddits.data;
|
||||
|
||||
@@ -20,6 +20,23 @@ interface OutreachCandidateView {
|
||||
availableAt: number;
|
||||
attemptCount: number;
|
||||
hasPreparedMessage: boolean;
|
||||
physiqueScore: number | null;
|
||||
preparation: {
|
||||
message: string;
|
||||
imageAnalysis: string;
|
||||
detectedGender: 'male' | 'female' | 'other' | 'unknown';
|
||||
attractivenessScore: number | null;
|
||||
physiqueScore: number | null;
|
||||
physiqueTargeting: { enabled: boolean; minScore: number; maxScore: number } | null;
|
||||
sourceImageUrl: string | null;
|
||||
strategyId: string | null;
|
||||
experimentId: string | null;
|
||||
promptVersion: string | null;
|
||||
selectionReason: 'explore' | 'exploit' | null;
|
||||
modelId: string;
|
||||
toneId: string;
|
||||
scripted: boolean;
|
||||
} | null;
|
||||
note: string | null;
|
||||
owner: { accountId: string; redditUsername: string } | null;
|
||||
}
|
||||
@@ -61,6 +78,23 @@ export function createOutreachRouter(ctx: WebContext): Router {
|
||||
availableAt: candidate.availableAt,
|
||||
attemptCount: candidate.attemptCount,
|
||||
hasPreparedMessage: candidate.prepared !== null,
|
||||
physiqueScore: candidate.prepared?.physiqueScore ?? null,
|
||||
preparation: candidate.prepared ? {
|
||||
message: candidate.prepared.generatedMessage,
|
||||
imageAnalysis: candidate.prepared.imageAnalysis,
|
||||
detectedGender: candidate.prepared.detectedGender,
|
||||
attractivenessScore: candidate.prepared.attractivenessScore ?? null,
|
||||
physiqueScore: candidate.prepared.physiqueScore ?? null,
|
||||
physiqueTargeting: candidate.prepared.physiqueTargeting ?? null,
|
||||
sourceImageUrl: candidate.prepared.sourceImageUrl ?? null,
|
||||
strategyId: candidate.prepared.strategyId,
|
||||
experimentId: candidate.prepared.experimentId,
|
||||
promptVersion: candidate.prepared.promptVersion,
|
||||
selectionReason: candidate.prepared.selectionReason,
|
||||
modelId: candidate.prepared.modelId,
|
||||
toneId: candidate.prepared.toneId,
|
||||
scripted: candidate.prepared.scripted,
|
||||
} : null,
|
||||
note: candidate.lastError,
|
||||
owner: candidate.assignedAccountId
|
||||
? {
|
||||
|
||||
@@ -103,6 +103,7 @@ export class WebServer {
|
||||
prioritizeRecentPosts: config.reddit.prioritizeRecentPosts,
|
||||
recentWindowMinutes: config.reddit.recentWindowMinutes,
|
||||
maxReadyCandidates: config.reddit.maxReadyCandidates,
|
||||
physiqueTargeting: { ...config.reddit.physiqueTargeting },
|
||||
},
|
||||
};
|
||||
this.app = express();
|
||||
|
||||
@@ -66,5 +66,10 @@ export interface RuntimeConfig {
|
||||
prioritizeRecentPosts: boolean;
|
||||
recentWindowMinutes: number;
|
||||
maxReadyCandidates: number;
|
||||
physiqueTargeting: {
|
||||
enabled: boolean;
|
||||
minScore: number;
|
||||
maxScore: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user