feat: expand outreach targeting and messaging

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

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
import type { PhysiqueTargeting } from '../config/index.js';
export type PhysiqueTargetingDecision =
| { accepted: true }
| { accepted: false; reason: string };
/**
* Applies the operator's optional physique range to an image analysis result.
* An enabled range is fail-closed: an image that cannot be scored cannot be
* claimed to fall inside the configured range.
*/
export function evaluatePhysiqueTargeting(
score: number | null,
targeting: PhysiqueTargeting,
): PhysiqueTargetingDecision {
if (!targeting.enabled) return { accepted: true };
if (score === null) {
return {
accepted: false,
reason: 'physique score unavailable while physique targeting is enabled',
};
}
if (score < targeting.minScore || score > targeting.maxScore) {
return {
accepted: false,
reason: `physique score ${score} outside target range ${targeting.minScore}-${targeting.maxScore}`,
};
}
return { accepted: true };
}
+51 -42
View File
@@ -1,16 +1,21 @@
import type { RedditApiClient, RedditPost } from '../reddit/api.js';
import type { OpenRouterClient, DetectedGender } from '../ai/openrouter.js';
import type { Logger } from '../utils/logger.js';
import type { Config } from '../config/index.js';
import type { Config, PhysiqueTargeting } from '../config/index.js';
import type { StrategySelector, SelectedStrategy } from './strategy-selector.js';
import { downloadImage, bufferToBase64, isValidImageMimeType, type DownloadedImage } from '../utils/image.js';
import { parseGenderTag } from '../utils/gender-tag.js';
import { resolveTone, type ToneProfile, type ToneKey } from '../config/tone-profiles.js';
import { evaluatePhysiqueTargeting } from './physique-targeting.js';
export interface ProcessedPost {
post: RedditPost;
imageAnalysis: string;
detectedGender: DetectedGender;
attractivenessScore: number | null;
physiqueScore: number | null;
physiqueTargeting: PhysiqueTargeting | null;
sourceImageUrl: string | null;
generatedMessage: string;
strategyId: string | null;
experimentId: string | null;
@@ -150,7 +155,28 @@ export class ProcessorService {
this.logger.debug(`Gender check passed: ${detectedGender}`);
}
// Select a strategy for A/B testing
// Analyze before strategy selection and opener generation so a rejected
// physique score costs only the analysis request.
let analysis;
try {
analysis = await this.aiClient.analyzeImage(base64, imageData.mimeType);
} catch (error) {
if (error instanceof Error && error.message.startsWith('AI_REFUSAL')) {
this.logger.warn(`AI refused to analyze post ${post.id} by u/${post.author} - skipping`);
return { skipReason: 'AI refused to analyze image' };
}
throw error;
}
const physiqueDecision = evaluatePhysiqueTargeting(
analysis.physiqueScore,
this.config.reddit.physiqueTargeting,
);
if (!physiqueDecision.accepted) {
this.logger.info(`Skipping post ${post.id} - ${physiqueDecision.reason}`);
return { skipReason: physiqueDecision.reason };
}
let selectedStrategy: SelectedStrategy | null = null;
if (this.strategySelector) {
selectedStrategy = this.strategySelector.selectStrategy();
@@ -159,7 +185,6 @@ export class ProcessorService {
}
}
// Resolve subreddit-specific tone profile
const tone = this.resolveTone(post.subreddit);
if (tone.scripted) {
this.logger.info(`Using scripted message for r/${post.subreddit}`);
@@ -167,47 +192,23 @@ export class ProcessorService {
this.logger.info(`Using tone "${tone.profile.label}" for r/${post.subreddit}`);
}
// Full analysis and message generation (only for matching posts)
let analysis;
let message;
try {
if (tone.scripted) {
// Replace substitution tokens in the scripted prompt with post-specific data
let scriptedPrompt = tone.scripted
const strategyPrompt = tone.scripted
? tone.scripted
.replace('{title}', post.title)
.replace('{author}', post.author)
.replace('{flair}', post.link_flair_text || '(none)');
({ analysis, message } = await this.aiClient.analyzeAndGenerateMessage(
base64,
imageData.mimeType,
post.title,
post.author,
this.successfulExamples,
scriptedPrompt,
post.link_flair_text ?? undefined,
undefined,
this.recentMessageProvider('image'),
));
} else {
({ analysis, message } = await this.aiClient.analyzeAndGenerateMessage(
base64,
imageData.mimeType,
post.title,
post.author,
this.successfulExamples,
selectedStrategy?.promptOverride,
post.link_flair_text ?? undefined,
tone.profile.promptGuidance || undefined,
this.recentMessageProvider('image'),
));
}
} catch (error) {
if (error instanceof Error && error.message.startsWith('AI_REFUSAL')) {
this.logger.warn(`AI refused to analyze post ${post.id} by u/${post.author} - skipping`);
return { skipReason: 'AI refused to analyze image' };
}
throw error;
}
.replace('{flair}', post.link_flair_text || '(none)')
: selectedStrategy?.promptOverride;
const message = await this.aiClient.generateMessage(
analysis.description,
post.title,
post.author,
this.successfulExamples,
strategyPrompt,
analysis.attractivenessScore,
post.link_flair_text ?? undefined,
tone.scripted ? undefined : tone.profile.promptGuidance || undefined,
this.recentMessageProvider('image'),
);
if (!message || message.length < 10) {
this.logger.warn(
@@ -220,6 +221,10 @@ export class ProcessorService {
post,
imageAnalysis: analysis.description,
detectedGender: analysis.detectedGender,
attractivenessScore: analysis.attractivenessScore,
physiqueScore: analysis.physiqueScore,
physiqueTargeting: { ...this.config.reddit.physiqueTargeting },
sourceImageUrl: imageUrl,
generatedMessage: message,
strategyId: selectedStrategy?.id || null,
experimentId: selectedStrategy?.experimentId ?? null,
@@ -304,6 +309,10 @@ export class ProcessorService {
post,
imageAnalysis: `[chat] ${post.title}${post.selftext ? ` — ${post.selftext.slice(0, 200)}` : ''}`,
detectedGender: detectedGender ?? 'unknown',
attractivenessScore: null,
physiqueScore: null,
physiqueTargeting: null,
sourceImageUrl: null,
generatedMessage: message,
strategyId: selectedStrategy?.id || null,
experimentId: selectedStrategy?.experimentId ?? null,
+140
View File
@@ -0,0 +1,140 @@
import type { RedditAccountRuntime, RedditAccountRuntimeManager } from './reddit-account-runtime.js';
import type { ActiveChat } from './chat/types.js';
import { buildConversationState, mergeConversationState } from './conversation-state.js';
export type RedditUserMessageResult = {
accountId: string;
redditUsername: string;
recipient: string;
conversationId: string;
reusedConversation: boolean;
};
type RuntimeConversation = { runtime: RedditAccountRuntime; conversation: ActiveChat };
/** Selects an account and performs one explicit, rate-limited operator/API send. */
export class RedditUserMessenger {
constructor(private readonly runtimes: RedditAccountRuntimeManager) {}
async send(username: string, message: string): Promise<RedditUserMessageResult> {
const recipient = username.trim().replace(/^u\//i, '');
const running = this.runningRuntimes();
if (running.length === 0) throw new RedditUserMessageError(503, 'No Reddit account is running');
const existing = await this.findExistingConversation(running, recipient);
if (existing) return this.sendExisting(existing, recipient, message);
const runtime = running
.filter((item) => item.resources.accountAuth.isFullyAuthenticated())
.filter((item) => !item.resources.chatService.getInvitationCooldown())
.sort(compareAvailableRuntimes)[0];
if (!runtime) throw new RedditUserMessageError(503, 'No authenticated Reddit account is available to start a conversation');
const started = await runtime.resources.rateLimiter.throttle(
() => runtime.resources.chatService.startConversationDetailed(recipient, message),
(result) => result.status === 'sent',
);
if (started.status !== 'sent') {
throw new RedditUserMessageError(started.status === 'terminal' ? 422 : 503, started.reason);
}
const conversationId = started.chat.roomId;
if (!conversationId) throw new RedditUserMessageError(502, 'Reddit confirmed the message without returning a conversation ID');
this.recordSend(runtime, recipient, `Started chat with u/${recipient}`);
runtime.resources.conversationStateRepo.save(
conversationId,
buildConversationState(started.chat.messages ?? [{ sender: 'Me', text: message, isSelf: true }]),
);
return resultFor(runtime, recipient, conversationId, false);
}
private runningRuntimes(): RedditAccountRuntime[] {
return this.runtimes.list()
.filter((snapshot) => snapshot.state === 'running')
.flatMap((snapshot) => {
const runtime = this.runtimes.get(snapshot.accountId);
return runtime ? [runtime] : [];
});
}
private async findExistingConversation(
runtimes: RedditAccountRuntime[],
username: string,
): Promise<RuntimeConversation | null> {
const normalized = normalizeUsername(username);
const matches = (await Promise.all(runtimes.map(async (runtime) =>
(await runtime.resources.chatService.getCachedConversations())
.filter((conversation) => normalizeUsername(conversation.username) === normalized)
.map((conversation) => ({ runtime, conversation })),
))).flat();
matches.sort((left, right) => right.conversation.lastMessageTime - left.conversation.lastMessageTime
|| left.runtime.session.redditUsername.localeCompare(right.runtime.session.redditUsername));
return matches[0] ?? null;
}
private async sendExisting(
selected: RuntimeConversation,
username: string,
message: string,
): Promise<RedditUserMessageResult> {
const { runtime, conversation } = selected;
if (!conversation.url || !conversation.roomId) {
throw new RedditUserMessageError(409, 'The existing conversation is missing its Matrix room details; synchronize chats and retry');
}
const sent = await runtime.resources.rateLimiter.throttle(
() => runtime.resources.chatService.sendChatMessage(conversation.url!, message),
(success) => success,
);
if (!sent) throw new RedditUserMessageError(502, 'Reddit did not confirm the message send');
const currentMessages = (conversation.messages ?? []).map(({ sender, text, isSelf }) => ({ sender, text, isSelf }));
const state = mergeConversationState(
runtime.resources.conversationStateRepo.get(conversation.roomId),
buildConversationState([...currentMessages, { sender: 'Me', text: message, isSelf: true }]),
);
state.automation.status = 'user-overridden';
state.automation.handoffReason = 'An API message was sent in this conversation.';
runtime.resources.conversationStateRepo.save(conversation.roomId, state);
this.recordSend(runtime, username, `Sent chat message to u/${username}`);
void runtime.resources.chatService.refreshTopChats(5).catch(() => undefined);
return resultFor(runtime, username, conversation.roomId, true);
}
private recordSend(runtime: RedditAccountRuntime, username: string, description: string): void {
runtime.resources.activityLogRepo.insert({
timestamp: Date.now(),
type: 'sent',
message: description,
recipient: username,
});
}
}
export class RedditUserMessageError extends Error {
constructor(readonly status: number, message: string) {
super(message);
}
}
function compareAvailableRuntimes(left: RedditAccountRuntime, right: RedditAccountRuntime): number {
return left.resources.rateLimiter.getStats().messagesThisHour
- right.resources.rateLimiter.getStats().messagesThisHour
|| left.session.redditUsername.localeCompare(right.session.redditUsername);
}
function normalizeUsername(username: string): string {
return username.trim().replace(/^u\//i, '').toLocaleLowerCase('en-US');
}
function resultFor(
runtime: RedditAccountRuntime,
recipient: string,
conversationId: string,
reusedConversation: boolean,
): RedditUserMessageResult {
return {
accountId: runtime.session.accountId,
redditUsername: runtime.session.redditUsername,
recipient: recipient.trim().replace(/^u\//i, ''),
conversationId,
reusedConversation,
};
}