Add subreddit tone profiles for message style guidance
Introduces a tone-profile system that lets the processor adapt outgoing message style per-subreddit (and per-flair) instead of using one tone for everything. Profiles act as a "tone mic" layered after the base prompt and strategy override, so they shape voice without redefining the whole message. - src/config/tone-profiles.ts defines the catalog (default, dominant, submissive, playful, direct, ...) and a resolveTone() helper that falls back to the default profile for unknown keys. - config.yaml maps subreddits to tone keys; the schema in src/config/index.ts validates it. - src/services/processor.ts threads the resolved tone into the prompt build, picking based on subreddit and optional link flair. - src/reddit/api.ts adds link_flair_text to RedditPost so the processor can use it for tone selection. - src/ai/openrouter.ts accepts and forwards the tone-shaped prompt. - public/index.html surfaces the active tone in the dashboard. - Tests cover the catalog, config validation, and processor integration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
0c7df6e1d3
commit
46a213e3e0
10 files changed
+497
-51
No files matched your search
+15
-12
@@ -6,23 +6,24 @@ reddit:
|
|||||||
# DM reply rates. Default is 1 if omitted.
|
# DM reply rates. Default is 1 if omitted.
|
||||||
subreddits:
|
subreddits:
|
||||||
# Validation-seeking / lower-confidence (highest weight)
|
# Validation-seeking / lower-confidence (highest weight)
|
||||||
- { name: AverageNudes, weight: 4 } # "Average-looking" self-perception
|
- { name: AverageNudes, weight: 4, tone: 'direct' } # "Average-looking" self-perception
|
||||||
- { name: gonewild40plus, weight: 4 } # Mature, lower volume, very high reply rate
|
- { name: gonewild40plus, weight: 4, tone: 'gentle' } # Mature, lower volume, very high reply rate
|
||||||
- { name: normalnudes, weight: 3 } # Everyday real bodies
|
- { name: normalnudes, weight: 3 } # Everyday real bodies
|
||||||
- { name: RateMyNudeBody, weight: 3 } # Explicit ask for feedback
|
- { name: RateMyNudeBody, weight: 3, tone: 'direct' } # Explicit ask for feedback
|
||||||
- { name: ratemyboobs, weight: 3 } # Rating subs = explicit validation-seeking
|
- { name: ratemyboobs, weight: 3, tone: 'direct' } # Rating subs = explicit validation-seeking
|
||||||
- { name: ratemypussy, weight: 3 }
|
- { name: ratemypussy, weight: 3, tone: 'direct' }
|
||||||
- { name: traumatizedsluts2, weight: 4 }
|
- { name: cnc_freaks, weight: 4, tone: 'kink-friendly' }
|
||||||
- { name: RateMyRack, weight: 3 }
|
- { name: traumatizedsluts2, weight: 4, tone: 'kink-friendly' }
|
||||||
- { name: GoneWildPlus, weight: 3 } # BBW/plus-size
|
- { name: RateMyRack, weight: 3, tone: 'direct' }
|
||||||
- { name: gonewildchubby, weight: 3 } # Soft/chubby bodies
|
- { name: GoneWildPlus, weight: 3, tone: 'gentle' } # BBW/plus-size
|
||||||
- { name: gonewild30plus, weight: 2 } # Mature/MILF amateurs
|
- { name: gonewildchubby, weight: 3, tone: 'gentle' } # Soft/chubby bodies
|
||||||
- { name: GoneMild, weight: 2 } # Softcore teases (shyer posters)
|
- { name: gonewild30plus, weight: 2, tone: 'gentle' } # Mature/MILF amateurs
|
||||||
|
- { name: GoneMild, weight: 2, tone: 'gentle' } # Softcore teases (shyer posters)
|
||||||
|
|
||||||
# Mid-tier — mixed confidence
|
# Mid-tier — mixed confidence
|
||||||
- { name: GoneWildCurvy, weight: 2 }
|
- { name: GoneWildCurvy, weight: 2 }
|
||||||
- { name: HomemadeNsfw, weight: 2 }
|
- { name: HomemadeNsfw, weight: 2 }
|
||||||
- { name: realgirlsgonebad, weight: 2 }
|
- { name: realgirlsgonebad, weight: 2, tone: 'dominant' }
|
||||||
- { name: nsfwselfies, weight: 2 }
|
- { name: nsfwselfies, weight: 2 }
|
||||||
- { name: RealGirls, weight: 2 }
|
- { name: RealGirls, weight: 2 }
|
||||||
- { name: Amateur, weight: 2 }
|
- { name: Amateur, weight: 2 }
|
||||||
@@ -98,6 +99,7 @@ openrouter:
|
|||||||
Based on this image analysis: {analysis}
|
Based on this image analysis: {analysis}
|
||||||
Post title: {title}
|
Post title: {title}
|
||||||
Author: u/{author}
|
Author: u/{author}
|
||||||
|
Post flair: {flair}
|
||||||
|
|
||||||
Attractiveness rating (1-100 scale, 50 = average): {rating}
|
Attractiveness rating (1-100 scale, 50 = average): {rating}
|
||||||
If the rating is below 60, lean harder into making her feel sexy and desired — emphasize how hot her pic looks. If the rating is 75+, you can be more confidently flirty without overdoing the flattery.
|
If the rating is below 60, lean harder into making her feel sexy and desired — emphasize how hot her pic looks. If the rating is 75+, you can be more confidently flirty without overdoing the flattery.
|
||||||
@@ -144,6 +146,7 @@ openrouter:
|
|||||||
Post body: {selftext}
|
Post body: {selftext}
|
||||||
Author: u/{author}
|
Author: u/{author}
|
||||||
Detected gender of poster: {gender}
|
Detected gender of poster: {gender}
|
||||||
|
Post flair: {flair}
|
||||||
|
|
||||||
YOUR PERSONA:
|
YOUR PERSONA:
|
||||||
- You are male, based in the UK, pick a random age between 30 and 35 for this message
|
- You are male, based in the UK, pick a random age between 30 and 35 for this message
|
||||||
|
|||||||
+36
-16
@@ -2730,6 +2730,8 @@
|
|||||||
? escapeHtml(msg.postTitle.length > 60 ? msg.postTitle.slice(0, 60) + '...' : msg.postTitle)
|
? escapeHtml(msg.postTitle.length > 60 ? msg.postTitle.slice(0, 60) + '...' : msg.postTitle)
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
const displayRecipient = formatUsernameRaw(msg.recipient);
|
||||||
|
|
||||||
const imageAnalysis = msg.postImageAnalysis
|
const imageAnalysis = msg.postImageAnalysis
|
||||||
? `<details class="message-image-analysis">
|
? `<details class="message-image-analysis">
|
||||||
<summary>Image analysis</summary>
|
<summary>Image analysis</summary>
|
||||||
@@ -2740,7 +2742,7 @@
|
|||||||
return `
|
return `
|
||||||
<div class="message-item">
|
<div class="message-item">
|
||||||
<div class="message-recipient">
|
<div class="message-recipient">
|
||||||
u/${escapeHtml(msg.recipient)}
|
u/${escapeHtml(displayRecipient)}
|
||||||
${msg.gotResponse ? '<span class="response-badge">Got Response!</span>' : ''}
|
${msg.gotResponse ? '<span class="response-badge">Got Response!</span>' : ''}
|
||||||
<a href="${postUrl}" target="_blank" style="margin-left:auto; color: var(--ink-soft); font-size: 11px; text-decoration: none; border: 1px solid var(--rule); padding: 3px 9px; border-radius: 999px; font-family: var(--f-mono);">
|
<a href="${postUrl}" target="_blank" style="margin-left:auto; color: var(--ink-soft); font-size: 11px; text-decoration: none; border: 1px solid var(--rule); padding: 3px 9px; border-radius: 999px; font-family: var(--f-mono);">
|
||||||
${sourceLabel} ↗
|
${sourceLabel} ↗
|
||||||
@@ -2779,6 +2781,19 @@
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Sanitise a username for safe display. t2_* and Matrix IDs are
|
||||||
|
Reddit platform identifiers — strip the t2_ prefix. Real usernames
|
||||||
|
do not start with t2_. */
|
||||||
|
function formatUsernameRaw(username) {
|
||||||
|
if (!username || username === 'unknown') return 'unknown';
|
||||||
|
// Strip the full MXID — handles @t2_abc123:reddit.com → t2_abc123
|
||||||
|
const mxidMatch = username.match(/^@(t2_[^:]+):/);
|
||||||
|
if (mxidMatch) return mxidMatch[1];
|
||||||
|
// t2_* is always a Matrix/platform ID — strip the prefix
|
||||||
|
const t2Match = username.replace(/^t2_/, '');
|
||||||
|
return t2Match;
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleBot() {
|
async function toggleBot() {
|
||||||
const controlBtn = document.getElementById('control-btn');
|
const controlBtn = document.getElementById('control-btn');
|
||||||
const isCurrentlyRunning = controlBtn.classList.contains('stop');
|
const isCurrentlyRunning = controlBtn.classList.contains('stop');
|
||||||
@@ -2869,14 +2884,16 @@
|
|||||||
|
|
||||||
container.innerHTML = chats.map(chat => {
|
container.innerHTML = chats.map(chat => {
|
||||||
const chatUrl = chat.url || 'https://chat.reddit.com';
|
const chatUrl = chat.url || 'https://chat.reddit.com';
|
||||||
const safeUsername = escapeHtml(chat.username);
|
const display = formatUsernameRaw(chat.username);
|
||||||
|
const displayName = display === 'unknown' ? 'unknown' : 'u/' + escapeHtml(display);
|
||||||
|
const initial = display.charAt(0).toUpperCase() || '?';
|
||||||
return `
|
return `
|
||||||
<div class="chat-item ${chat.unread ? 'unread' : ''}" style="cursor: pointer;" onclick="openConversation('${safeUsername}')">
|
<div class="chat-item ${chat.unread ? 'unread' : ''}" style="cursor: pointer;" onclick="openConversation('${escapeHtml(display)}')">
|
||||||
<div class="chat-avatar">${chat.username.charAt(0).toUpperCase()}</div>
|
<div class="chat-avatar">${initial}</div>
|
||||||
<div class="chat-content">
|
<div class="chat-content">
|
||||||
<div class="chat-header">
|
<div class="chat-header">
|
||||||
<span class="chat-username">
|
<span class="chat-username">
|
||||||
u/${safeUsername}
|
${displayName}
|
||||||
<a href="${chatUrl}" target="_blank" rel="noopener" title="Open in Reddit" style="text-decoration: none; margin-left: 4px;" onclick="event.stopPropagation()">↗</a>
|
<a href="${chatUrl}" target="_blank" rel="noopener" title="Open in Reddit" style="text-decoration: none; margin-left: 4px;" onclick="event.stopPropagation()">↗</a>
|
||||||
${chat.unread ? '<span class="unread-badge">Unread</span>' : ''}
|
${chat.unread ? '<span class="unread-badge">Unread</span>' : ''}
|
||||||
</span>
|
</span>
|
||||||
@@ -3094,13 +3111,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = items.map(item => {
|
container.innerHTML = items.map(item => {
|
||||||
const username = item.chatUsername || item.sender || null;
|
const username = formatUsernameRaw(item.chatUsername || item.sender || '');
|
||||||
const timestamp = item.createdAt || item.timestamp || Date.now();
|
const timestamp = item.createdAt || item.timestamp || Date.now();
|
||||||
const mediaUrl = item.urlPath || '';
|
const mediaUrl = item.urlPath || '';
|
||||||
const userLabel = username ? 'u/' + escapeHtml(username) : 'unknown sender';
|
const rawUser = item.chatUsername || item.sender || '';
|
||||||
// Open the conversation modal if we can identify the user; the
|
const userLabel = rawUser ? 'u/' + escapeHtml(username) : 'unknown sender';
|
||||||
// image itself stays click-through to a new tab.
|
const userClick = rawUser
|
||||||
const userClick = username
|
|
||||||
? `onclick="event.stopPropagation(); openConversation('${escapeHtml(username)}')" style="cursor: pointer; text-decoration: underline;"`
|
? `onclick="event.stopPropagation(); openConversation('${escapeHtml(username)}')" style="cursor: pointer; text-decoration: underline;"`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
@@ -3110,7 +3126,7 @@
|
|||||||
<img src="${mediaUrl}" alt="Chat media from ${escapeHtml(username || 'unknown')}" loading="lazy" />
|
<img src="${mediaUrl}" alt="Chat media from ${escapeHtml(username || 'unknown')}" loading="lazy" />
|
||||||
</a>
|
</a>
|
||||||
<div class="media-meta">
|
<div class="media-meta">
|
||||||
<span class="media-user" ${userClick} title="${username ? 'Open chat' : 'Recipient unknown'}">${userLabel}</span>
|
<span class="media-user" ${userClick} title="${rawUser ? 'Open chat' : 'Recipient unknown'}">${userLabel}</span>
|
||||||
<span>${formatTime(timestamp)}</span>
|
<span>${formatTime(timestamp)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3282,8 +3298,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = filtered.map(conv => {
|
container.innerHTML = filtered.map(conv => {
|
||||||
|
const display = formatUsernameRaw(conv.username);
|
||||||
|
const displayName = display === 'unknown' ? 'unknown' : 'u/' + escapeHtml(display);
|
||||||
|
const initial = display.charAt(0).toUpperCase() || '?';
|
||||||
const messageCount = conv.messages ? conv.messages.length : 0;
|
const messageCount = conv.messages ? conv.messages.length : 0;
|
||||||
const hasReply = conv.messages ? conv.messages.some(m => !m.isSelf) : false;
|
const hasReply = conv.messages ? conv.messages.some(m => !m.isSelf) : false;
|
||||||
|
|
||||||
const origin = conv.originMessage ? conv.originMessage.slice(0, 60) + '...' : conv.lastMessageSnippet;
|
const origin = conv.originMessage ? conv.originMessage.slice(0, 60) + '...' : conv.lastMessageSnippet;
|
||||||
|
|
||||||
let cardClass = 'conversation-card';
|
let cardClass = 'conversation-card';
|
||||||
@@ -3292,12 +3312,12 @@
|
|||||||
const statusBadge = hasReply ? '<span class="reply-indicator">Got Reply!</span>' : '';
|
const statusBadge = hasReply ? '<span class="reply-indicator">Got Reply!</span>' : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="${cardClass}" onclick="openConversation('${escapeHtml(conv.username)}')">
|
<div class="${cardClass}" onclick="openConversation('${escapeHtml(display)}')">
|
||||||
<div class="chat-avatar">${conv.username.charAt(0).toUpperCase()}</div>
|
<div class="chat-avatar">${initial}</div>
|
||||||
<div class="chat-content">
|
<div class="chat-content">
|
||||||
<div class="chat-header">
|
<div class="chat-header">
|
||||||
<span class="chat-username">
|
<span class="chat-username">
|
||||||
u/${escapeHtml(conv.username)}
|
${displayName}
|
||||||
<span class="message-count-badge">${messageCount} msgs</span>
|
<span class="message-count-badge">${messageCount} msgs</span>
|
||||||
${statusBadge}
|
${statusBadge}
|
||||||
</span>
|
</span>
|
||||||
@@ -3343,7 +3363,7 @@
|
|||||||
const conv = await res.json();
|
const conv = await res.json();
|
||||||
|
|
||||||
let metaHtml = '<div class="conversation-meta">';
|
let metaHtml = '<div class="conversation-meta">';
|
||||||
metaHtml += `<div class="meta-item">Room: <span>${escapeHtml(conv.roomName)}</span></div>`;
|
metaHtml += `<div class="meta-item">Room: <span>${escapeHtml(formatUsernameRaw(conv.roomName))}</span></div>`;
|
||||||
metaHtml += `<div class="meta-item">Last activity: <span>${formatTime(conv.lastMessageTime)}</span></div>`;
|
metaHtml += `<div class="meta-item">Last activity: <span>${formatTime(conv.lastMessageTime)}</span></div>`;
|
||||||
if (conv.url) {
|
if (conv.url) {
|
||||||
metaHtml += `<div class="meta-item"><a href="${conv.url}" target="_blank" rel="noopener">Open in Reddit ↗</a></div>`;
|
metaHtml += `<div class="meta-item"><a href="${conv.url}" target="_blank" rel="noopener">Open in Reddit ↗</a></div>`;
|
||||||
@@ -3383,7 +3403,7 @@
|
|||||||
if (conv.messages && conv.messages.length > 0) {
|
if (conv.messages && conv.messages.length > 0) {
|
||||||
messagesHtml = conv.messages.map(msg => {
|
messagesHtml = conv.messages.map(msg => {
|
||||||
const bubbleClass = msg.isSelf ? 'sent' : 'received';
|
const bubbleClass = msg.isSelf ? 'sent' : 'received';
|
||||||
const senderLabel = msg.isSelf ? 'You' : msg.sender;
|
const senderLabel = msg.isSelf ? 'You' : escapeHtml(formatUsernameRaw(msg.sender));
|
||||||
const hasMedia = Array.isArray(msg.media) && msg.media.length > 0;
|
const hasMedia = Array.isArray(msg.media) && msg.media.length > 0;
|
||||||
// Suppress the "[Image]" placeholder text when we have the image to render.
|
// Suppress the "[Image]" placeholder text when we have the image to render.
|
||||||
const showText = msg.text && !(hasMedia && msg.text.trim() === '[Image]');
|
const showText = msg.text && !(hasMedia && msg.text.trim() === '[Image]');
|
||||||
|
|||||||
+21
-4
@@ -226,6 +226,8 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
|
|||||||
successfulExamples: string[] = [],
|
successfulExamples: string[] = [],
|
||||||
strategyPrompt?: string,
|
strategyPrompt?: string,
|
||||||
attractivenessScore: number | null = null,
|
attractivenessScore: number | null = null,
|
||||||
|
postFlair?: string | null,
|
||||||
|
toneGuidance?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
this.logger.debug('Generating message...');
|
this.logger.debug('Generating message...');
|
||||||
@@ -234,13 +236,19 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
|
|||||||
.replace('{analysis}', imageAnalysis)
|
.replace('{analysis}', imageAnalysis)
|
||||||
.replace('{title}', postTitle)
|
.replace('{title}', postTitle)
|
||||||
.replace('{author}', authorName)
|
.replace('{author}', authorName)
|
||||||
.replace('{rating}', attractivenessScore != null ? String(attractivenessScore) : 'unknown');
|
.replace('{rating}', attractivenessScore != null ? String(attractivenessScore) : 'unknown')
|
||||||
|
.replace('{flair}', postFlair || '(no flair)');
|
||||||
|
|
||||||
// Append strategy-specific style guidance if provided
|
// Append strategy-specific style guidance if provided
|
||||||
if (strategyPrompt) {
|
if (strategyPrompt) {
|
||||||
prompt += `\n\n${strategyPrompt}`;
|
prompt += `\n\n${strategyPrompt}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append tone guidance if provided
|
||||||
|
if (toneGuidance) {
|
||||||
|
prompt += `\n\n${toneGuidance}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Add successful examples if available
|
// Add successful examples if available
|
||||||
if (successfulExamples.length > 0) {
|
if (successfulExamples.length > 0) {
|
||||||
const examplesText = successfulExamples
|
const examplesText = successfulExamples
|
||||||
@@ -371,6 +379,8 @@ Just write the message, nothing else.`;
|
|||||||
detectedGender: DetectedGender,
|
detectedGender: DetectedGender,
|
||||||
successfulExamples: string[] = [],
|
successfulExamples: string[] = [],
|
||||||
strategyPrompt?: string,
|
strategyPrompt?: string,
|
||||||
|
postFlair?: string | null,
|
||||||
|
toneGuidance?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
this.logger.debug('Generating chat opener...');
|
this.logger.debug('Generating chat opener...');
|
||||||
@@ -379,12 +389,17 @@ Just write the message, nothing else.`;
|
|||||||
.replace('{title}', postTitle)
|
.replace('{title}', postTitle)
|
||||||
.replace('{selftext}', selftext || '(no body)')
|
.replace('{selftext}', selftext || '(no body)')
|
||||||
.replace('{author}', authorName)
|
.replace('{author}', authorName)
|
||||||
.replace('{gender}', detectedGender);
|
.replace('{gender}', detectedGender)
|
||||||
|
.replace('{flair}', postFlair || '(no flair)');
|
||||||
|
|
||||||
if (strategyPrompt) {
|
if (strategyPrompt) {
|
||||||
prompt += `\n\n${strategyPrompt}`;
|
prompt += `\n\n${strategyPrompt}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (toneGuidance) {
|
||||||
|
prompt += `\n\n${toneGuidance}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (successfulExamples.length > 0) {
|
if (successfulExamples.length > 0) {
|
||||||
const examplesText = successfulExamples
|
const examplesText = successfulExamples
|
||||||
.map((ex, i) => `Example ${i + 1}:\n${ex}`)
|
.map((ex, i) => `Example ${i + 1}:\n${ex}`)
|
||||||
@@ -482,10 +497,12 @@ Just write the message, nothing else.`;
|
|||||||
postTitle: string,
|
postTitle: string,
|
||||||
authorName: string,
|
authorName: string,
|
||||||
successfulExamples: string[] = [],
|
successfulExamples: string[] = [],
|
||||||
strategyPrompt?: string
|
strategyPrompt?: string,
|
||||||
|
postFlair?: string | null,
|
||||||
|
toneGuidance?: string,
|
||||||
): Promise<{ analysis: ImageAnalysisResult; message: string }> {
|
): Promise<{ analysis: ImageAnalysisResult; message: string }> {
|
||||||
const analysis = await this.analyzeImage(imageBase64, mimeType);
|
const analysis = await this.analyzeImage(imageBase64, mimeType);
|
||||||
const message = await this.generateMessage(analysis.description, postTitle, authorName, successfulExamples, strategyPrompt, analysis.attractivenessScore);
|
const message = await this.generateMessage(analysis.description, postTitle, authorName, successfulExamples, strategyPrompt, analysis.attractivenessScore, postFlair, toneGuidance);
|
||||||
return { analysis, message };
|
return { analysis, message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import YAML from 'yaml';
|
import YAML from 'yaml';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import { TONE_KEYS } from './tone-profiles.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -10,6 +11,8 @@ export const SubredditConfigSchema = z.object({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
type: z.enum(['image', 'chat']).default('image'),
|
type: z.enum(['image', 'chat']).default('image'),
|
||||||
weight: z.number().positive().optional(),
|
weight: z.number().positive().optional(),
|
||||||
|
tone: z.enum(TONE_KEYS).default('default'),
|
||||||
|
scripted: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SubredditConfig = z.infer<typeof SubredditConfigSchema>;
|
export type SubredditConfig = z.infer<typeof SubredditConfigSchema>;
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Tone profiles for subreddit-specific message style guidance.
|
||||||
|
*
|
||||||
|
* Each profile defines body-language instructions the LLM uses to adapt the
|
||||||
|
* message to the subreddit's culture. Profiles are applied AFTER the base
|
||||||
|
* prompt and strategy override, so they act as a tone mic rather than
|
||||||
|
* redefining the entire message.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const TONE_KEYS = [
|
||||||
|
'default',
|
||||||
|
'dominant',
|
||||||
|
'submissive',
|
||||||
|
'playful',
|
||||||
|
'direct',
|
||||||
|
'gentle',
|
||||||
|
'kink-friendly',
|
||||||
|
'teasing',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ToneKey = (typeof TONE_KEYS)[number];
|
||||||
|
|
||||||
|
export interface ToneProfile {
|
||||||
|
id: ToneKey;
|
||||||
|
/** One-line label shown in logs. */
|
||||||
|
label: string;
|
||||||
|
/** Instructions injected into the prompt to shape the AI's tone. */
|
||||||
|
promptGuidance: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TONE_PROFILES: Record<ToneKey, ToneProfile> = {
|
||||||
|
default: {
|
||||||
|
id: 'default',
|
||||||
|
label: 'Standard',
|
||||||
|
promptGuidance: '',
|
||||||
|
},
|
||||||
|
dominant: {
|
||||||
|
id: 'dominant',
|
||||||
|
label: 'Dominant',
|
||||||
|
promptGuidance:
|
||||||
|
'STYLE TONE: Be direct, firm, and commanding. Use imperative language ("send me", "I want", "show me"). ' +
|
||||||
|
'Don\'t ask hesitantly — tell. Don\'t apologise or hedge. ' +
|
||||||
|
'Be clear about what you want from her. Make it feel like an order wrapped in lust, not a request.',
|
||||||
|
},
|
||||||
|
submissive: {
|
||||||
|
id: 'submissive',
|
||||||
|
label: 'Submissive',
|
||||||
|
promptGuidance:
|
||||||
|
'STYLE TONE: Be deferential and eager to please. Ask what she wants and how you can make her feel good. ' +
|
||||||
|
'Use language like "I\'d love to" "please" "if you\'d let me". ' +
|
||||||
|
'Show enthusiasm for following her lead. Be warm, attentive, and completely focused on her pleasure.',
|
||||||
|
},
|
||||||
|
playful: {
|
||||||
|
id: 'playful',
|
||||||
|
label: 'Playful',
|
||||||
|
promptGuidance:
|
||||||
|
'STYLE TONE: Be fun, teasing, and light-hearted. Make her smile. Use playful challenges, light teasing, ' +
|
||||||
|
'and cheeky banter. Keep it hot but with a grin — like you\'re having fun while flirting.',
|
||||||
|
},
|
||||||
|
direct: {
|
||||||
|
id: 'direct',
|
||||||
|
label: 'Direct',
|
||||||
|
promptGuidance:
|
||||||
|
'STYLE TONE: Cut straight to the point. No preamble, no dance. Say exactly what you noticed and what you want. ' +
|
||||||
|
'Be honest and raw — she posted nudes to get a reaction, give her one. One or two sentences max.',
|
||||||
|
},
|
||||||
|
gentle: {
|
||||||
|
id: 'gentle',
|
||||||
|
label: 'Gentle',
|
||||||
|
promptGuidance:
|
||||||
|
'STYLE TONE: Be warm, complimenting, and let her feel desired without being crude. ' +
|
||||||
|
'Focus on how her body makes you feel rather than explicit demands. ' +
|
||||||
|
'Use phrases like "you\'re amazing", "I love your curves", "you look incredible". ' +
|
||||||
|
'Still flirty and sexual but in a softer, more admiring way.',
|
||||||
|
},
|
||||||
|
'kink-friendly': {
|
||||||
|
id: 'kink-friendly',
|
||||||
|
label: 'Kink-Friendly',
|
||||||
|
promptGuidance:
|
||||||
|
'STYLE TONE: Be shameless and open about kinks. This is a kink-friendly subspace — ' +
|
||||||
|
'don\'t hold back. Use explicit language freely. If the post hints at something specific, ' +
|
||||||
|
'respond to that directly. Be bold about desires, into anything vibe. ' +
|
||||||
|
'Reference kink-aware language naturally (dom/sub, bdsm, control, discipline, force) if it fits.',
|
||||||
|
},
|
||||||
|
teasing: {
|
||||||
|
id: 'teasing',
|
||||||
|
label: 'Teasing',
|
||||||
|
promptGuidance:
|
||||||
|
'STYLE TONE: Set up a payoff but don\'t deliver it straight away. Tease, provoke, make her ask for more. ' +
|
||||||
|
'Use challenges ("bet you won\'t"), withhold compliments slightly, make her work for it. ' +
|
||||||
|
'Leave her wanting to prove something or respond.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveTone(tone: string): ToneProfile {
|
||||||
|
const key = TONE_KEYS.find((k) => k === tone) ?? 'default';
|
||||||
|
return TONE_PROFILES[key];
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ export interface RedditPost {
|
|||||||
created_utc: number;
|
created_utc: number;
|
||||||
over_18: boolean;
|
over_18: boolean;
|
||||||
spoiler: boolean;
|
spoiler: boolean;
|
||||||
|
link_flair_text?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extracts URL-ish tokens (with or without scheme) and returns the first
|
// Extracts URL-ish tokens (with or without scheme) and returns the first
|
||||||
|
|||||||
+94
-16
@@ -5,6 +5,7 @@ import type { Config } 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';
|
||||||
|
|
||||||
export interface ProcessedPost {
|
export interface ProcessedPost {
|
||||||
post: RedditPost;
|
post: RedditPost;
|
||||||
@@ -12,6 +13,8 @@ export interface ProcessedPost {
|
|||||||
detectedGender: DetectedGender;
|
detectedGender: DetectedGender;
|
||||||
generatedMessage: string;
|
generatedMessage: string;
|
||||||
strategyId: string | null;
|
strategyId: string | null;
|
||||||
|
toneId: ToneKey;
|
||||||
|
scripted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkipResult {
|
export interface SkipResult {
|
||||||
@@ -61,6 +64,22 @@ export class ProcessorService {
|
|||||||
return entry?.type ?? 'image';
|
return entry?.type ?? 'image';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getSubredditEntry(name: string): NonNullable<typeof entry> | undefined {
|
||||||
|
const entry = this.config.reddit.subreddits.find((s) => s.name.toLowerCase() === name.toLowerCase());
|
||||||
|
return entry ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveTone(name: string): { profile: ToneProfile; scripted: string | null } {
|
||||||
|
const entry = this.getSubredditEntry(name);
|
||||||
|
if (!entry) {
|
||||||
|
return { profile: resolveTone('default'), scripted: null };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
profile: resolveTone(entry.tone),
|
||||||
|
scripted: entry.scripted ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
setSuccessfulExamples(examples: string[]): void {
|
setSuccessfulExamples(examples: string[]): void {
|
||||||
this.successfulExamples = examples;
|
this.successfulExamples = examples;
|
||||||
if (examples.length > 0) {
|
if (examples.length > 0) {
|
||||||
@@ -133,18 +152,46 @@ 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}`);
|
||||||
|
} else if (tone.profile.id !== 'default') {
|
||||||
|
this.logger.info(`Using tone "${tone.profile.label}" for r/${post.subreddit}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Full analysis and message generation (only for matching posts)
|
// Full analysis and message generation (only for matching posts)
|
||||||
let analysis;
|
let analysis;
|
||||||
let message;
|
let message;
|
||||||
try {
|
try {
|
||||||
({ analysis, message } = await this.aiClient.analyzeAndGenerateMessage(
|
if (tone.scripted) {
|
||||||
base64,
|
// Replace substitution tokens in the scripted prompt with post-specific data
|
||||||
imageData.mimeType,
|
let scriptedPrompt = tone.scripted
|
||||||
post.title,
|
.replace('{title}', post.title)
|
||||||
post.author,
|
.replace('{author}', post.author)
|
||||||
this.successfulExamples,
|
.replace('{flair}', post.link_flair_text || '(none)');
|
||||||
selectedStrategy?.promptOverride
|
({ analysis, message } = await this.aiClient.analyzeAndGenerateMessage(
|
||||||
));
|
base64,
|
||||||
|
imageData.mimeType,
|
||||||
|
post.title,
|
||||||
|
post.author,
|
||||||
|
this.successfulExamples,
|
||||||
|
scriptedPrompt,
|
||||||
|
post.link_flair_text ?? undefined,
|
||||||
|
undefined
|
||||||
|
));
|
||||||
|
} 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,
|
||||||
|
));
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message.startsWith('AI_REFUSAL')) {
|
if (error instanceof Error && error.message.startsWith('AI_REFUSAL')) {
|
||||||
this.logger.warn(`AI refused to analyze post ${post.id} by u/${post.author} - skipping`);
|
this.logger.warn(`AI refused to analyze post ${post.id} by u/${post.author} - skipping`);
|
||||||
@@ -166,6 +213,8 @@ export class ProcessorService {
|
|||||||
detectedGender: analysis.detectedGender,
|
detectedGender: analysis.detectedGender,
|
||||||
generatedMessage: message,
|
generatedMessage: message,
|
||||||
strategyId: selectedStrategy?.id || null,
|
strategyId: selectedStrategy?.id || null,
|
||||||
|
toneId: tone.profile.id,
|
||||||
|
scripted: tone.scripted !== null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,16 +235,43 @@ export class ProcessorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve subreddit-specific tone profile
|
||||||
|
const tone = this.resolveTone(post.subreddit);
|
||||||
|
if (tone.scripted) {
|
||||||
|
this.logger.info(`Using scripted opener for r/${post.subreddit}`);
|
||||||
|
} else if (tone.profile.id !== 'default') {
|
||||||
|
this.logger.info(`Using tone "${tone.profile.label}" for r/${post.subreddit}`);
|
||||||
|
}
|
||||||
|
|
||||||
let message: string;
|
let message: string;
|
||||||
try {
|
try {
|
||||||
message = await this.aiClient.generateChatOpener(
|
if (tone.scripted) {
|
||||||
post.title,
|
let scriptedPrompt = tone.scripted
|
||||||
post.selftext ?? '',
|
.replace('{title}', post.title)
|
||||||
post.author,
|
.replace('{author}', post.author)
|
||||||
detectedGender ?? 'unknown',
|
.replace('{flair}', post.link_flair_text || '(none)');
|
||||||
this.successfulExamples,
|
message = await this.aiClient.generateChatOpener(
|
||||||
selectedStrategy?.promptOverride,
|
post.title,
|
||||||
);
|
post.selftext ?? '',
|
||||||
|
post.author,
|
||||||
|
detectedGender ?? 'unknown',
|
||||||
|
this.successfulExamples,
|
||||||
|
scriptedPrompt,
|
||||||
|
post.link_flair_text ?? undefined,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
message = await this.aiClient.generateChatOpener(
|
||||||
|
post.title,
|
||||||
|
post.selftext ?? '',
|
||||||
|
post.author,
|
||||||
|
detectedGender ?? 'unknown',
|
||||||
|
this.successfulExamples,
|
||||||
|
selectedStrategy?.promptOverride,
|
||||||
|
post.link_flair_text ?? undefined,
|
||||||
|
tone.profile.promptGuidance || undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Chat opener generation failed for post ${post.id}:`, error);
|
this.logger.error(`Chat opener generation failed for post ${post.id}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -214,6 +290,8 @@ export class ProcessorService {
|
|||||||
detectedGender: detectedGender ?? 'unknown',
|
detectedGender: detectedGender ?? 'unknown',
|
||||||
generatedMessage: message,
|
generatedMessage: message,
|
||||||
strategyId: selectedStrategy?.id || null,
|
strategyId: selectedStrategy?.id || null,
|
||||||
|
toneId: tone.profile.id,
|
||||||
|
scripted: tone.scripted !== null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+38
-1
@@ -41,6 +41,43 @@ describe('ConfigSchema', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies default tone to subreddits', () => {
|
||||||
|
const result = ConfigSchema.safeParse(minimalConfig({ reddit: { subreddits: ['test'] } }));
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.reddit.subreddits[0].tone).toBe('default');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts valid tone values', () => {
|
||||||
|
for (const tone of ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing']) {
|
||||||
|
const result = ConfigSchema.safeParse({
|
||||||
|
reddit: { subreddits: [{ name: 'test', tone }] },
|
||||||
|
openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' },
|
||||||
|
rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: {},
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid tone', () => {
|
||||||
|
const result = ConfigSchema.safeParse({
|
||||||
|
reddit: { subreddits: [{ name: 'test', tone: 'bananas' }] },
|
||||||
|
openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' },
|
||||||
|
rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: {},
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts scripted field', () => {
|
||||||
|
const result = ConfigSchema.safeParse({
|
||||||
|
reddit: { subreddits: [{ name: 'test', scripted: 'Hello {author}' }] },
|
||||||
|
openrouter: { apiKey: 'key', imageAnalysisPrompt: 'test', messageGenerationPrompt: 'test' },
|
||||||
|
rateLimit: {}, browser: {}, database: {}, media: {}, strategies: {}, web: {},
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts valid targetGenders', () => {
|
it('accepts valid targetGenders', () => {
|
||||||
const result = ConfigSchema.safeParse(minimalConfig({
|
const result = ConfigSchema.safeParse(minimalConfig({
|
||||||
reddit: { subreddits: ['test'], targetGenders: ['male', 'female', 'other', 'unknown'] },
|
reddit: { subreddits: ['test'], targetGenders: ['male', 'female', 'other', 'unknown'] },
|
||||||
@@ -306,7 +343,7 @@ openrouter:
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
expect(config.reddit.subreddits).toEqual([{ name: 'test-sub', type: 'image' }]);
|
expect(config.reddit.subreddits).toEqual([{ name: 'test-sub', type: 'image', tone: 'default' }]);
|
||||||
expect(config.reddit.pollIntervalMs).toBe(5000);
|
expect(config.reddit.pollIntervalMs).toBe(5000);
|
||||||
expect(config.openrouter.apiKey).toBe('yaml-key');
|
expect(config.openrouter.apiKey).toBe('yaml-key');
|
||||||
});
|
});
|
||||||
|
|||||||
+119
-2
@@ -11,6 +11,7 @@ const fakeFetchImage: ImageFetcher = async () => ({
|
|||||||
import type { StrategySelector, SelectedStrategy } from '../src/services/strategy-selector.js';
|
import type { StrategySelector, SelectedStrategy } from '../src/services/strategy-selector.js';
|
||||||
import { createMockLogger } from '../tests/mockLogger.js';
|
import { createMockLogger } from '../tests/mockLogger.js';
|
||||||
import { getTestConfig } from '../tests/testUtils.js';
|
import { getTestConfig } from '../tests/testUtils.js';
|
||||||
|
import type { ToneKey } from '../src/config/tone-profiles.js';
|
||||||
|
|
||||||
function createMockRedditApi(imageUrl: string | null = 'http://example.com/img.jpg'): RedditApiClient {
|
function createMockRedditApi(imageUrl: string | null = 'http://example.com/img.jpg'): RedditApiClient {
|
||||||
return {
|
return {
|
||||||
@@ -32,9 +33,10 @@ function createMockAiClient(
|
|||||||
generateMessage: vi.fn(async (analysis: string) => message),
|
generateMessage: vi.fn(async (analysis: string) => 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) => {
|
analyzeAndGenerateMessage: vi.fn(async (base64: string, mimeType: string, title: string, author: string, examples?: string[], strategyPrompt?: string, postFlair?: string | null, toneGuidance?: string) => {
|
||||||
return { analysis, message };
|
return { analysis, message };
|
||||||
}),
|
}),
|
||||||
|
generateChatOpener: vi.fn(async () => message),
|
||||||
} as unknown as OpenRouterClient;
|
} as unknown as OpenRouterClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +90,8 @@ describe('ProcessorService', () => {
|
|||||||
expect(result.imageAnalysis).toBe('Beautiful woman');
|
expect(result.imageAnalysis).toBe('Beautiful woman');
|
||||||
expect(result.detectedGender).toBe('female');
|
expect(result.detectedGender).toBe('female');
|
||||||
expect(result.generatedMessage).toBe('Hey gorgeous!');
|
expect(result.generatedMessage).toBe('Hey gorgeous!');
|
||||||
|
expect(result.toneId).toBe('default');
|
||||||
|
expect(result.scripted).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when no image URL', async () => {
|
it('returns null when no image URL', async () => {
|
||||||
@@ -190,6 +194,8 @@ describe('ProcessorService', () => {
|
|||||||
'Test Post',
|
'Test Post',
|
||||||
'testuser',
|
'testuser',
|
||||||
['Example 1: Hey', 'Example 2: Nice'],
|
['Example 1: Hey', 'Example 2: Nice'],
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -210,7 +216,9 @@ describe('ProcessorService', () => {
|
|||||||
'Test Post',
|
'Test Post',
|
||||||
'testuser',
|
'testuser',
|
||||||
[],
|
[],
|
||||||
'Be teasing'
|
'Be teasing',
|
||||||
|
undefined,
|
||||||
|
undefined
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -250,6 +258,8 @@ describe('ProcessorService', () => {
|
|||||||
'Test Post',
|
'Test Post',
|
||||||
'testuser',
|
'testuser',
|
||||||
['Example A'],
|
['Example A'],
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -304,6 +314,8 @@ describe('ProcessorService', () => {
|
|||||||
'female',
|
'female',
|
||||||
[],
|
[],
|
||||||
undefined,
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -333,6 +345,8 @@ describe('ProcessorService', () => {
|
|||||||
'female',
|
'female',
|
||||||
[],
|
[],
|
||||||
'Be playful',
|
'Be playful',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -346,4 +360,107 @@ describe('ProcessorService', () => {
|
|||||||
expect(logExists).toBe(false);
|
expect(logExists).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('subreddit tone', () => {
|
||||||
|
it('logs tone name when non-default tone is set', async () => {
|
||||||
|
const config = getTestConfig({
|
||||||
|
reddit: {
|
||||||
|
subreddits: [
|
||||||
|
{ name: 'test1', type: 'image', tone: 'kink-friendly' },
|
||||||
|
{ name: 'test2', type: 'image' },
|
||||||
|
],
|
||||||
|
targetGenders: ['female'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
|
||||||
|
|
||||||
|
const tonedPost = { ...mockPost, subreddit: 'test1' };
|
||||||
|
await processor.processPost(tonedPost);
|
||||||
|
|
||||||
|
expect(logger.logs.some(l => l.message.includes('Using tone "Kink-Friendly"'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects tone guidance in message generation', async () => {
|
||||||
|
const config = getTestConfig({
|
||||||
|
reddit: {
|
||||||
|
subreddits: [
|
||||||
|
{ name: 'test1', type: 'image', tone: 'dominant' },
|
||||||
|
{ name: 'test2', type: 'image' },
|
||||||
|
],
|
||||||
|
targetGenders: ['female'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
|
||||||
|
|
||||||
|
const tonedPost = { ...mockPost, subreddit: 'test1' };
|
||||||
|
await processor.processPost(tonedPost);
|
||||||
|
|
||||||
|
// 8th arg (index 7) after strategyPrompt and postFlair is toneGuidance
|
||||||
|
const callArgs = vi.mocked(aiClient.analyzeAndGenerateMessage).mock.calls[0];
|
||||||
|
expect(callArgs[7]).toContain('STYLE TONE');
|
||||||
|
expect(callArgs[7]).toContain('commanding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates result with correct toneId', async () => {
|
||||||
|
const config = getTestConfig({
|
||||||
|
reddit: {
|
||||||
|
subreddits: [
|
||||||
|
{ name: 'test1', type: 'image', tone: 'gentle' },
|
||||||
|
{ name: 'test2', type: 'image' },
|
||||||
|
],
|
||||||
|
targetGenders: ['female'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
|
||||||
|
|
||||||
|
const tonedPost = { ...mockPost, subreddit: 'test1' };
|
||||||
|
const result = await processor.processPost(tonedPost);
|
||||||
|
if (isSkip(result)) throw new Error(`expected success, got skip: ${result.skipReason}`);
|
||||||
|
|
||||||
|
expect(result.toneId).toBe('gentle');
|
||||||
|
expect(result.scripted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes tone guidance to chat opener', async () => {
|
||||||
|
const chatPost: RedditPost = {
|
||||||
|
...mockPost,
|
||||||
|
id: 't3_chat_tone',
|
||||||
|
subreddit: 'SextingRush',
|
||||||
|
title: '[25F] looking for a dom',
|
||||||
|
selftext: 'want to serve',
|
||||||
|
is_self: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = getTestConfig({
|
||||||
|
reddit: {
|
||||||
|
subreddits: [
|
||||||
|
{ name: 'SextingRush', type: 'chat', tone: 'dominant' },
|
||||||
|
],
|
||||||
|
targetGenders: ['female'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ai = {
|
||||||
|
...createMockAiClient(),
|
||||||
|
generateChatOpener: vi.fn(async () => 'Kneel down.'),
|
||||||
|
} as unknown as OpenRouterClient;
|
||||||
|
processor = new ProcessorService(redditApi, ai, config, logger, fakeFetchImage);
|
||||||
|
|
||||||
|
await processor.processPost(chatPost);
|
||||||
|
|
||||||
|
const callArgs = vi.mocked(ai.generateChatOpener).mock.calls[0];
|
||||||
|
expect(callArgs[7]).toContain('STYLE TONE');
|
||||||
|
expect(callArgs[7]).toContain('imperative');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default tone when subreddit is not configured', async () => {
|
||||||
|
const config = getTestConfig();
|
||||||
|
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
|
||||||
|
|
||||||
|
const unknownPost = { ...mockPost, subreddit: 'unknownsub' };
|
||||||
|
const result = await processor.processPost(unknownPost);
|
||||||
|
if (isSkip(result)) throw new Error(`expected success, got skip: ${result.skipReason}`);
|
||||||
|
|
||||||
|
expect(result.toneId).toBe('default');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
TONE_KEYS,
|
||||||
|
TONE_PROFILES,
|
||||||
|
resolveTone,
|
||||||
|
type ToneKey,
|
||||||
|
} from '../src/config/tone-profiles.js';
|
||||||
|
|
||||||
|
describe('Tone profiles', () => {
|
||||||
|
describe('TONE_KEYS', () => {
|
||||||
|
it('includes default', () => {
|
||||||
|
expect(TONE_KEYS).toContain('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes all expected profile keys', () => {
|
||||||
|
const expected: ToneKey[] = [
|
||||||
|
'default',
|
||||||
|
'dominant',
|
||||||
|
'submissive',
|
||||||
|
'playful',
|
||||||
|
'direct',
|
||||||
|
'gentle',
|
||||||
|
'kink-friendly',
|
||||||
|
'teasing',
|
||||||
|
];
|
||||||
|
expect(TONE_KEYS).toEqual(expect.arrayContaining(expected));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defines no duplicate keys', () => {
|
||||||
|
expect(new Set(TONE_KEYS).size).toBe(TONE_KEYS.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TONE_PROFILES', () => {
|
||||||
|
for (const key of TONE_KEYS) {
|
||||||
|
it(`has a profile for ${key}`, () => {
|
||||||
|
expect(TONE_PROFILES[key]).toBeDefined();
|
||||||
|
expect(TONE_PROFILES[key].id).toBe(key);
|
||||||
|
expect(typeof TONE_PROFILES[key].label).toBe('string');
|
||||||
|
expect(typeof TONE_PROFILES[key].promptGuidance).toBe('string');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('default profile has empty guidance', () => {
|
||||||
|
expect(TONE_PROFILES.default.promptGuidance).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-default profiles have non-empty guidance', () => {
|
||||||
|
for (const key of TONE_KEYS) {
|
||||||
|
if (key === 'default') continue;
|
||||||
|
expect(TONE_PROFILES[key].promptGuidance.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveTone', () => {
|
||||||
|
it('returns default for unknown tone string', () => {
|
||||||
|
const result = resolveTone('nonexistent');
|
||||||
|
expect(result.id).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the matching profile for valid keys', () => {
|
||||||
|
expect(resolveTone('dominant').id).toBe('dominant');
|
||||||
|
expect(resolveTone('submissive').id).toBe('submissive');
|
||||||
|
expect(resolveTone('kink-friendly').id).toBe('kink-friendly');
|
||||||
|
expect(resolveTone('gentle').id).toBe('gentle');
|
||||||
|
expect(resolveTone('teasing').id).toBe('teasing');
|
||||||
|
expect(resolveTone('playful').id).toBe('playful');
|
||||||
|
expect(resolveTone('direct').id).toBe('direct');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user