Restructure chat service, web routes, and add test suite
Split monolithic chat service into modular structure with Matrix client support, extract web server into per-resource route modules, introduce bootstrap/main-loop separation, and add comprehensive vitest coverage across services, repositories, and utilities. Adds project docs and CLAUDE.md agent instructions.
This commit is contained in:
1 parent
dfa0580859
commit
633bb0ec04
84 files changed
+19278
-3888
No files matched your search
+211
-27
@@ -4,9 +4,18 @@ import type { Logger } from '../utils/logger.js';
|
||||
|
||||
export type DetectedGender = 'male' | 'female' | 'other' | 'unknown';
|
||||
|
||||
export function stripDashes(text: string): string {
|
||||
return text
|
||||
.replace(/\s*[—–]\s*/g, ', ')
|
||||
.replace(/,\s*,/g, ',')
|
||||
.replace(/,\s*\./g, '.')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export interface ImageAnalysisResult {
|
||||
description: string;
|
||||
detectedGender: DetectedGender;
|
||||
attractivenessScore: number | null;
|
||||
}
|
||||
|
||||
export class OpenRouterClient {
|
||||
@@ -14,9 +23,10 @@ export class OpenRouterClient {
|
||||
|
||||
constructor(
|
||||
private config: Config,
|
||||
private logger: Logger
|
||||
private logger: Logger,
|
||||
client?: OpenAI,
|
||||
) {
|
||||
this.client = new OpenAI({
|
||||
this.client = client ?? new OpenAI({
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
apiKey: config.openrouter.apiKey,
|
||||
defaultHeaders: {
|
||||
@@ -70,6 +80,9 @@ export class OpenRouterClient {
|
||||
...this.config.openrouter.imageAnalysisFallbackModels,
|
||||
];
|
||||
|
||||
let lastError: unknown = null;
|
||||
let sawRefusal = false;
|
||||
|
||||
for (let i = 0; i < models.length; i++) {
|
||||
const model = models[i];
|
||||
const isFallback = i > 0;
|
||||
@@ -90,14 +103,22 @@ export class OpenRouterClient {
|
||||
}
|
||||
|
||||
// Result was null = refusal, try next model
|
||||
sawRefusal = true;
|
||||
this.logger.warn(`Model ${model} refused to analyze image${i < models.length - 1 ? ', trying fallback...' : ''}`);
|
||||
} catch (error) {
|
||||
// API error (not a refusal) - still try fallback
|
||||
// API error (not a refusal) — try next model but remember the error.
|
||||
lastError = error;
|
||||
this.logger.warn(`Model ${model} failed: ${error instanceof Error ? error.message : error}${i < models.length - 1 ? ', trying fallback...' : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
// All models refused or failed
|
||||
// If any model returned a refusal, surface AI_REFUSAL so callers can branch
|
||||
// on it. Otherwise propagate the most recent API error so failures aren't
|
||||
// masked as refusals.
|
||||
if (sawRefusal) {
|
||||
throw new Error('AI_REFUSAL: All models refused to analyze image');
|
||||
}
|
||||
if (lastError) throw lastError;
|
||||
throw new Error('AI_REFUSAL: All models refused to analyze image');
|
||||
}
|
||||
|
||||
@@ -134,23 +155,37 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
|
||||
|
||||
// Parse gender from response
|
||||
let detectedGender: DetectedGender = 'unknown';
|
||||
const genderMatch = fullResponse.match(/GENDER:(male|female|other|unknown)/i);
|
||||
const genderMatch = fullResponse.match(/GENDER:\s*(male|female|other|unknown)/i);
|
||||
if (genderMatch) {
|
||||
detectedGender = genderMatch[1].toLowerCase() as DetectedGender;
|
||||
}
|
||||
|
||||
// Remove gender line from description
|
||||
const description = fullResponse.replace(/GENDER:(male|female|other|unknown)\n?/i, '').trim();
|
||||
// Parse attractiveness rating (1-100 scale) from response.
|
||||
// Tolerate markdown wrapping like **RATING:** 63 or RATING: **63**.
|
||||
let attractivenessScore: number | null = null;
|
||||
const ratingMatch = fullResponse.match(/RATING[:\s*]*[*_\s]*(\d{1,3})/i);
|
||||
if (ratingMatch) {
|
||||
const parsed = parseInt(ratingMatch[1], 10);
|
||||
if (parsed >= 1 && parsed <= 100) {
|
||||
attractivenessScore = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove gender and rating lines from description
|
||||
const description = fullResponse
|
||||
.replace(/GENDER:\s*(male|female|other|unknown)\n?/i, '')
|
||||
.replace(/\**\s*RATING[:\s*]*[*_\s]*\d{1,3}\**\n?/i, '')
|
||||
.trim();
|
||||
|
||||
// Detect AI refusal - return null to signal fallback should be tried
|
||||
if (this.isRefusal(description)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.debug(`Image analysis complete (${model}) - Gender: ${detectedGender}`);
|
||||
this.logger.debug(`Image analysis complete (${model}) - Gender: ${detectedGender}, Rating: ${attractivenessScore ?? 'n/a'}`);
|
||||
this.logger.debug(`Description: ${description}`);
|
||||
|
||||
return { description, detectedGender };
|
||||
return { description, detectedGender, attractivenessScore };
|
||||
}
|
||||
|
||||
private isRefusal(text: string): boolean {
|
||||
@@ -189,7 +224,8 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
|
||||
postTitle: string,
|
||||
authorName: string,
|
||||
successfulExamples: string[] = [],
|
||||
strategyPrompt?: string
|
||||
strategyPrompt?: string,
|
||||
attractivenessScore: number | null = null,
|
||||
): Promise<string> {
|
||||
try {
|
||||
this.logger.debug('Generating message...');
|
||||
@@ -197,7 +233,8 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
|
||||
let prompt = this.config.openrouter.messageGenerationPrompt
|
||||
.replace('{analysis}', imageAnalysis)
|
||||
.replace('{title}', postTitle)
|
||||
.replace('{author}', authorName);
|
||||
.replace('{author}', authorName)
|
||||
.replace('{rating}', attractivenessScore != null ? String(attractivenessScore) : 'unknown');
|
||||
|
||||
// Append strategy-specific style guidance if provided
|
||||
if (strategyPrompt) {
|
||||
@@ -215,16 +252,11 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
|
||||
this.logger.debug(`Including ${successfulExamples.length} successful examples in prompt`);
|
||||
}
|
||||
|
||||
const response = await this.client.chat.completions.create({
|
||||
model: this.config.openrouter.chatModel,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
const message = await this.generateTextWithFallback('generateMessage', prompt, {
|
||||
max_tokens: 300,
|
||||
temperature: 0.8, // Add some creativity
|
||||
temperature: 0.8,
|
||||
});
|
||||
|
||||
const message = response.choices[0]?.message?.content || '';
|
||||
this.logger.debug(`Message generated: ${message}`);
|
||||
return message.trim();
|
||||
return message;
|
||||
} catch (error) {
|
||||
this.logger.error('Message generation failed:', error);
|
||||
throw error;
|
||||
@@ -256,22 +288,62 @@ DO NOT:
|
||||
|
||||
Just write the message, nothing else.`;
|
||||
|
||||
const response = await this.client.chat.completions.create({
|
||||
model: this.config.openrouter.chatModel,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
const message = await this.generateTextWithFallback('generateReply', prompt, {
|
||||
max_tokens: 300,
|
||||
temperature: 0.8,
|
||||
});
|
||||
|
||||
const message = response.choices[0]?.message?.content || '';
|
||||
this.logger.debug(`Suggested reply generated: ${message}`);
|
||||
return message.trim();
|
||||
return message;
|
||||
} catch (error) {
|
||||
this.logger.error('Reply generation failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async generateMediaAsk(
|
||||
conversationHistory: { sender: string; text: string; isSelf: boolean }[],
|
||||
username: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
this.logger.debug(`Generating media-ask message for conversation with ${username}...`);
|
||||
|
||||
const historyText = conversationHistory
|
||||
.map(msg => `${msg.isSelf ? 'You' : msg.sender}: ${msg.text}`)
|
||||
.join('\n');
|
||||
|
||||
const prompt = `You are helping continue a Reddit chat conversation. Here is the conversation so far:
|
||||
|
||||
${historyText}
|
||||
|
||||
Write the next message to send to ${username}. The goal of THIS message is to playfully nudge them into sending a pic or video OF THEMSELVES — their body, face, outfit, what they look like right now, a selfie. NOT a pic of an object, place, food, pet, or scenery. Keep the same tone and style as the previous messages from "You" — casual, flirty, direct, never robotic. Keep it to 1-3 sentences. You can reference something they just said as a hook, but the ask itself must be about seeing THEM.
|
||||
|
||||
Pick ONE technique that fits the current vibe — don't combine them, don't telegraph that you're using one:
|
||||
- Playful dare ("bet you won't send a selfie right now")
|
||||
- Direct ask for them ("send me a pic", "lemme see you", "selfie?")
|
||||
- Tease about how they look / sound ("now I have to see what you look like")
|
||||
- Curiosity hook about their appearance ("ok I need a face to picture here")
|
||||
- Compliment + ask ("you sound hot, prove it 😏", "show me that smile")
|
||||
- Outfit / "what are you wearing" angle ("send a pic of the fit")
|
||||
|
||||
DO NOT:
|
||||
- Sound like an AI or use formal language
|
||||
- Use words like "captivating", "alluring", "exquisite", "radiant"
|
||||
- Beg, sound desperate, or repeat an ask they already declined
|
||||
- Write long elaborate sentences
|
||||
- Mention OnlyFans, paying, or anything transactional
|
||||
|
||||
Just write the message, nothing else.`;
|
||||
|
||||
const message = await this.generateTextWithFallback('generateMediaAsk', prompt, {
|
||||
max_tokens: 300,
|
||||
temperature: 0.9,
|
||||
});
|
||||
return message;
|
||||
} catch (error) {
|
||||
this.logger.error('Media-ask generation failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if message content contains upsell/spam keywords (OnlyFans, selling, etc.)
|
||||
*/
|
||||
@@ -292,6 +364,118 @@ Just write the message, nothing else.`;
|
||||
return { isUpsell: false };
|
||||
}
|
||||
|
||||
async generateChatOpener(
|
||||
postTitle: string,
|
||||
selftext: string,
|
||||
authorName: string,
|
||||
detectedGender: DetectedGender,
|
||||
successfulExamples: string[] = [],
|
||||
strategyPrompt?: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
this.logger.debug('Generating chat opener...');
|
||||
|
||||
let prompt = this.config.openrouter.chatOpenerPrompt
|
||||
.replace('{title}', postTitle)
|
||||
.replace('{selftext}', selftext || '(no body)')
|
||||
.replace('{author}', authorName)
|
||||
.replace('{gender}', detectedGender);
|
||||
|
||||
if (strategyPrompt) {
|
||||
prompt += `\n\n${strategyPrompt}`;
|
||||
}
|
||||
|
||||
if (successfulExamples.length > 0) {
|
||||
const examplesText = successfulExamples
|
||||
.map((ex, i) => `Example ${i + 1}:\n${ex}`)
|
||||
.join('\n\n');
|
||||
prompt = `Here are openers that got responses:\n\n${examplesText}\n\n---\n\nNow generate a new opener in a similar style:\n\n${prompt}`;
|
||||
}
|
||||
|
||||
const message = await this.generateTextWithFallback('generateChatOpener', prompt, {
|
||||
max_tokens: 200,
|
||||
temperature: 0.85,
|
||||
});
|
||||
return message;
|
||||
} catch (error) {
|
||||
this.logger.error('Chat opener generation failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try the primary chatModel and then chatFallbackModels in order. A model is
|
||||
* considered to have failed (and the next is tried) when it throws, returns
|
||||
* empty content, or returns a refusal. Returns the trimmed message from the
|
||||
* first model that produces usable content; returns '' if none do.
|
||||
*/
|
||||
private async generateTextWithFallback(
|
||||
label: string,
|
||||
prompt: string,
|
||||
opts: { max_tokens: number; temperature: number },
|
||||
): Promise<string> {
|
||||
const models = [
|
||||
this.config.openrouter.chatModel,
|
||||
...this.config.openrouter.chatFallbackModels,
|
||||
];
|
||||
|
||||
this.logger.info(`[AI] ${label} prompt (${prompt.length} chars):\n${prompt}`);
|
||||
|
||||
let lastError: unknown = null;
|
||||
for (let i = 0; i < models.length; i++) {
|
||||
const model = models[i];
|
||||
const isFallback = i > 0;
|
||||
try {
|
||||
if (isFallback) {
|
||||
this.logger.info(`[AI] ${label} trying fallback model: ${model}`);
|
||||
}
|
||||
const response = await this.client.chat.completions.create({
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
max_tokens: opts.max_tokens,
|
||||
temperature: opts.temperature,
|
||||
});
|
||||
|
||||
const raw = response.choices[0]?.message?.content || '';
|
||||
const choice = response.choices[0] as unknown as Record<string, unknown> | undefined;
|
||||
const choiceMessage = choice?.message as Record<string, unknown> | undefined;
|
||||
const respAny = response as unknown as Record<string, unknown>;
|
||||
const meta = {
|
||||
finish_reason: choice?.finish_reason,
|
||||
native_finish_reason: choice?.native_finish_reason,
|
||||
refusal: choiceMessage?.refusal,
|
||||
model: response.model,
|
||||
provider: respAny.provider,
|
||||
usage: response.usage,
|
||||
};
|
||||
this.logger.info(`[AI] ${label} response from ${model} (${raw.length} chars) meta=${JSON.stringify(meta)}: ${JSON.stringify(raw)}`);
|
||||
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
this.logger.warn(`[AI] ${label}: ${model} returned empty content${i < models.length - 1 ? ', trying fallback...' : ''}`);
|
||||
continue;
|
||||
}
|
||||
if (this.isRefusal(trimmed)) {
|
||||
this.logger.warn(`[AI] ${label}: ${model} returned a refusal${i < models.length - 1 ? ', trying fallback...' : ''}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isFallback) {
|
||||
this.logger.info(`[AI] ${label}: fallback model ${model} succeeded`);
|
||||
}
|
||||
return stripDashes(trimmed);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
this.logger.warn(`[AI] ${label}: ${model} threw ${error instanceof Error ? error.message : String(error)}${i < models.length - 1 ? ', trying fallback...' : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async analyzeAndGenerateMessage(
|
||||
imageBase64: string,
|
||||
mimeType: string,
|
||||
@@ -301,7 +485,7 @@ Just write the message, nothing else.`;
|
||||
strategyPrompt?: string
|
||||
): Promise<{ analysis: ImageAnalysisResult; message: string }> {
|
||||
const analysis = await this.analyzeImage(imageBase64, mimeType);
|
||||
const message = await this.generateMessage(analysis.description, postTitle, authorName, successfulExamples, strategyPrompt);
|
||||
const message = await this.generateMessage(analysis.description, postTitle, authorName, successfulExamples, strategyPrompt, analysis.attractivenessScore);
|
||||
return { analysis, message };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user