diff --git a/config.yaml b/config.yaml index def227a..ff50cc4 100644 --- a/config.yaml +++ b/config.yaml @@ -6,23 +6,24 @@ reddit: # DM reply rates. Default is 1 if omitted. subreddits: # Validation-seeking / lower-confidence (highest weight) - - { name: AverageNudes, weight: 4 } # "Average-looking" self-perception - - { name: gonewild40plus, weight: 4 } # Mature, lower volume, very high reply rate + - { name: AverageNudes, weight: 4, tone: 'direct' } # "Average-looking" self-perception + - { name: gonewild40plus, weight: 4, tone: 'gentle' } # Mature, lower volume, very high reply rate - { name: normalnudes, weight: 3 } # Everyday real bodies - - { name: RateMyNudeBody, weight: 3 } # Explicit ask for feedback - - { name: ratemyboobs, weight: 3 } # Rating subs = explicit validation-seeking - - { name: ratemypussy, weight: 3 } - - { name: traumatizedsluts2, weight: 4 } - - { name: RateMyRack, weight: 3 } - - { name: GoneWildPlus, weight: 3 } # BBW/plus-size - - { name: gonewildchubby, weight: 3 } # Soft/chubby bodies - - { name: gonewild30plus, weight: 2 } # Mature/MILF amateurs - - { name: GoneMild, weight: 2 } # Softcore teases (shyer posters) + - { name: RateMyNudeBody, weight: 3, tone: 'direct' } # Explicit ask for feedback + - { name: ratemyboobs, weight: 3, tone: 'direct' } # Rating subs = explicit validation-seeking + - { name: ratemypussy, weight: 3, tone: 'direct' } + - { name: cnc_freaks, weight: 4, tone: 'kink-friendly' } + - { name: traumatizedsluts2, weight: 4, tone: 'kink-friendly' } + - { name: RateMyRack, weight: 3, tone: 'direct' } + - { name: GoneWildPlus, weight: 3, tone: 'gentle' } # BBW/plus-size + - { name: gonewildchubby, weight: 3, tone: 'gentle' } # Soft/chubby bodies + - { name: gonewild30plus, weight: 2, tone: 'gentle' } # Mature/MILF amateurs + - { name: GoneMild, weight: 2, tone: 'gentle' } # Softcore teases (shyer posters) # Mid-tier — mixed confidence - { name: GoneWildCurvy, weight: 2 } - { name: HomemadeNsfw, weight: 2 } - - { name: realgirlsgonebad, weight: 2 } + - { name: realgirlsgonebad, weight: 2, tone: 'dominant' } - { name: nsfwselfies, weight: 2 } - { name: RealGirls, weight: 2 } - { name: Amateur, weight: 2 } @@ -98,6 +99,7 @@ openrouter: Based on this image analysis: {analysis} Post title: {title} Author: u/{author} + Post flair: {flair} 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. @@ -144,6 +146,7 @@ openrouter: Post body: {selftext} Author: u/{author} Detected gender of poster: {gender} + Post flair: {flair} YOUR PERSONA: - You are male, based in the UK, pick a random age between 30 and 35 for this message diff --git a/public/index.html b/public/index.html index 158fa71..e01dce8 100644 --- a/public/index.html +++ b/public/index.html @@ -2730,6 +2730,8 @@ ? escapeHtml(msg.postTitle.length > 60 ? msg.postTitle.slice(0, 60) + '...' : msg.postTitle) : ''; + const displayRecipient = formatUsernameRaw(msg.recipient); + const imageAnalysis = msg.postImageAnalysis ? `
Image analysis @@ -2740,7 +2742,7 @@ return `
- u/${escapeHtml(msg.recipient)} + u/${escapeHtml(displayRecipient)} ${msg.gotResponse ? 'Got Response!' : ''} ${sourceLabel} ↗ @@ -2779,6 +2781,19 @@ 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() { const controlBtn = document.getElementById('control-btn'); const isCurrentlyRunning = controlBtn.classList.contains('stop'); @@ -2869,14 +2884,16 @@ container.innerHTML = chats.map(chat => { 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 ` -
-
${chat.username.charAt(0).toUpperCase()}
+
+
${initial}
- u/${safeUsername} + ${displayName} ↗ ${chat.unread ? 'Unread' : ''} @@ -3094,13 +3111,12 @@ } 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 mediaUrl = item.urlPath || ''; - const userLabel = username ? 'u/' + escapeHtml(username) : 'unknown sender'; - // Open the conversation modal if we can identify the user; the - // image itself stays click-through to a new tab. - const userClick = username + const rawUser = item.chatUsername || item.sender || ''; + const userLabel = rawUser ? 'u/' + escapeHtml(username) : 'unknown sender'; + const userClick = rawUser ? `onclick="event.stopPropagation(); openConversation('${escapeHtml(username)}')" style="cursor: pointer; text-decoration: underline;"` : ''; @@ -3110,7 +3126,7 @@ Chat media from ${escapeHtml(username || 'unknown')}
- ${userLabel} + ${userLabel} ${formatTime(timestamp)}
@@ -3282,8 +3298,12 @@ } 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 hasReply = conv.messages ? conv.messages.some(m => !m.isSelf) : false; + const origin = conv.originMessage ? conv.originMessage.slice(0, 60) + '...' : conv.lastMessageSnippet; let cardClass = 'conversation-card'; @@ -3292,12 +3312,12 @@ const statusBadge = hasReply ? 'Got Reply!' : ''; return ` -
-
${conv.username.charAt(0).toUpperCase()}
+
+
${initial}
- u/${escapeHtml(conv.username)} + ${displayName} ${messageCount} msgs ${statusBadge} @@ -3343,7 +3363,7 @@ const conv = await res.json(); let metaHtml = '
'; - metaHtml += `
Room: ${escapeHtml(conv.roomName)}
`; + metaHtml += `
Room: ${escapeHtml(formatUsernameRaw(conv.roomName))}
`; metaHtml += `
Last activity: ${formatTime(conv.lastMessageTime)}
`; if (conv.url) { metaHtml += ``; @@ -3383,7 +3403,7 @@ if (conv.messages && conv.messages.length > 0) { messagesHtml = conv.messages.map(msg => { 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; // Suppress the "[Image]" placeholder text when we have the image to render. const showText = msg.text && !(hasMedia && msg.text.trim() === '[Image]'); diff --git a/src/ai/openrouter.ts b/src/ai/openrouter.ts index 1d0b7c8..c8b6ad5 100644 --- a/src/ai/openrouter.ts +++ b/src/ai/openrouter.ts @@ -226,6 +226,8 @@ ${this.config.openrouter.imageAnalysisPrompt}`; successfulExamples: string[] = [], strategyPrompt?: string, attractivenessScore: number | null = null, + postFlair?: string | null, + toneGuidance?: string, ): Promise { try { this.logger.debug('Generating message...'); @@ -234,13 +236,19 @@ ${this.config.openrouter.imageAnalysisPrompt}`; .replace('{analysis}', imageAnalysis) .replace('{title}', postTitle) .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 if (strategyPrompt) { prompt += `\n\n${strategyPrompt}`; } + // Append tone guidance if provided + if (toneGuidance) { + prompt += `\n\n${toneGuidance}`; + } + // Add successful examples if available if (successfulExamples.length > 0) { const examplesText = successfulExamples @@ -371,6 +379,8 @@ Just write the message, nothing else.`; detectedGender: DetectedGender, successfulExamples: string[] = [], strategyPrompt?: string, + postFlair?: string | null, + toneGuidance?: string, ): Promise { try { this.logger.debug('Generating chat opener...'); @@ -379,12 +389,17 @@ Just write the message, nothing else.`; .replace('{title}', postTitle) .replace('{selftext}', selftext || '(no body)') .replace('{author}', authorName) - .replace('{gender}', detectedGender); + .replace('{gender}', detectedGender) + .replace('{flair}', postFlair || '(no flair)'); if (strategyPrompt) { prompt += `\n\n${strategyPrompt}`; } + if (toneGuidance) { + prompt += `\n\n${toneGuidance}`; + } + if (successfulExamples.length > 0) { const examplesText = successfulExamples .map((ex, i) => `Example ${i + 1}:\n${ex}`) @@ -482,10 +497,12 @@ Just write the message, nothing else.`; postTitle: string, authorName: string, successfulExamples: string[] = [], - strategyPrompt?: string + strategyPrompt?: string, + postFlair?: string | null, + toneGuidance?: string, ): Promise<{ analysis: ImageAnalysisResult; message: string }> { 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 }; } } diff --git a/src/config/index.ts b/src/config/index.ts index ca5ee9e..77c0c2f 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -3,6 +3,7 @@ import fs from 'fs'; import path from 'path'; import YAML from 'yaml'; import dotenv from 'dotenv'; +import { TONE_KEYS } from './tone-profiles.js'; dotenv.config(); @@ -10,6 +11,8 @@ export const SubredditConfigSchema = z.object({ name: z.string(), type: z.enum(['image', 'chat']).default('image'), weight: z.number().positive().optional(), + tone: z.enum(TONE_KEYS).default('default'), + scripted: z.string().optional(), }); export type SubredditConfig = z.infer; diff --git a/src/config/tone-profiles.ts b/src/config/tone-profiles.ts new file mode 100644 index 0000000..8f9df0b --- /dev/null +++ b/src/config/tone-profiles.ts @@ -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 = { + 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]; +} diff --git a/src/reddit/api.ts b/src/reddit/api.ts index 3953c45..9f74011 100644 --- a/src/reddit/api.ts +++ b/src/reddit/api.ts @@ -24,6 +24,7 @@ export interface RedditPost { created_utc: number; over_18: boolean; spoiler: boolean; + link_flair_text?: string | null; } // Extracts URL-ish tokens (with or without scheme) and returns the first diff --git a/src/services/processor.ts b/src/services/processor.ts index 86d5224..3136744 100644 --- a/src/services/processor.ts +++ b/src/services/processor.ts @@ -5,6 +5,7 @@ import type { Config } 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'; export interface ProcessedPost { post: RedditPost; @@ -12,6 +13,8 @@ export interface ProcessedPost { detectedGender: DetectedGender; generatedMessage: string; strategyId: string | null; + toneId: ToneKey; + scripted: boolean; } export interface SkipResult { @@ -61,6 +64,22 @@ export class ProcessorService { return entry?.type ?? 'image'; } + private getSubredditEntry(name: string): NonNullable | 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 { this.successfulExamples = examples; 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) let analysis; let message; try { - ({ analysis, message } = await this.aiClient.analyzeAndGenerateMessage( - base64, - imageData.mimeType, - post.title, - post.author, - this.successfulExamples, - selectedStrategy?.promptOverride - )); + if (tone.scripted) { + // Replace substitution tokens in the scripted prompt with post-specific data + let scriptedPrompt = 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 + )); + } 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) { if (error instanceof Error && error.message.startsWith('AI_REFUSAL')) { 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, generatedMessage: message, 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; try { - message = await this.aiClient.generateChatOpener( - post.title, - post.selftext ?? '', - post.author, - detectedGender ?? 'unknown', - this.successfulExamples, - selectedStrategy?.promptOverride, - ); + if (tone.scripted) { + let scriptedPrompt = tone.scripted + .replace('{title}', post.title) + .replace('{author}', post.author) + .replace('{flair}', post.link_flair_text || '(none)'); + message = await this.aiClient.generateChatOpener( + 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) { this.logger.error(`Chat opener generation failed for post ${post.id}:`, error); throw error; @@ -214,6 +290,8 @@ export class ProcessorService { detectedGender: detectedGender ?? 'unknown', generatedMessage: message, strategyId: selectedStrategy?.id || null, + toneId: tone.profile.id, + scripted: tone.scripted !== null, }; } } diff --git a/tests/config.test.ts b/tests/config.test.ts index ed27c94..17ccd89 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -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', () => { const result = ConfigSchema.safeParse(minimalConfig({ reddit: { subreddits: ['test'], targetGenders: ['male', 'female', 'other', 'unknown'] }, @@ -306,7 +343,7 @@ openrouter: `); 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.openrouter.apiKey).toBe('yaml-key'); }); diff --git a/tests/processor.test.ts b/tests/processor.test.ts index 70729ae..2e3cdee 100644 --- a/tests/processor.test.ts +++ b/tests/processor.test.ts @@ -11,6 +11,7 @@ const fakeFetchImage: ImageFetcher = async () => ({ import type { StrategySelector, SelectedStrategy } from '../src/services/strategy-selector.js'; import { createMockLogger } from '../tests/mockLogger.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 { return { @@ -32,9 +33,10 @@ function createMockAiClient( generateMessage: vi.fn(async (analysis: string) => message), generateReply: vi.fn(async () => 'Reply'), detectUpsell: vi.fn(() => ({ isUpsell: false })), - analyzeAndGenerateMessage: vi.fn(async (base64: string, mimeType: string, title: string, author: string, examples?: string[], strategyPrompt?: string) => { + analyzeAndGenerateMessage: vi.fn(async (base64: string, mimeType: string, title: string, author: string, examples?: string[], strategyPrompt?: string, postFlair?: string | null, toneGuidance?: string) => { return { analysis, message }; }), + generateChatOpener: vi.fn(async () => message), } as unknown as OpenRouterClient; } @@ -88,6 +90,8 @@ describe('ProcessorService', () => { expect(result.imageAnalysis).toBe('Beautiful woman'); expect(result.detectedGender).toBe('female'); expect(result.generatedMessage).toBe('Hey gorgeous!'); + expect(result.toneId).toBe('default'); + expect(result.scripted).toBe(false); }); it('returns null when no image URL', async () => { @@ -190,6 +194,8 @@ describe('ProcessorService', () => { 'Test Post', 'testuser', ['Example 1: Hey', 'Example 2: Nice'], + undefined, + undefined, undefined ); }); @@ -210,7 +216,9 @@ describe('ProcessorService', () => { 'Test Post', 'testuser', [], - 'Be teasing' + 'Be teasing', + undefined, + undefined ); }); @@ -250,6 +258,8 @@ describe('ProcessorService', () => { 'Test Post', 'testuser', ['Example A'], + undefined, + undefined, undefined ); }); @@ -304,6 +314,8 @@ describe('ProcessorService', () => { 'female', [], undefined, + undefined, + undefined, ); }); @@ -333,6 +345,8 @@ describe('ProcessorService', () => { 'female', [], 'Be playful', + undefined, + undefined, ); }); }); @@ -346,4 +360,107 @@ describe('ProcessorService', () => { 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'); + }); + }); }); diff --git a/tests/tone-profiles.test.ts b/tests/tone-profiles.test.ts new file mode 100644 index 0000000..3abe4e1 --- /dev/null +++ b/tests/tone-profiles.test.ts @@ -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'); + }); + }); +});