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:
jjbrunton committed 2026-05-21 01:32:10 +01:00
1 parent dfa0580859
commit 633bb0ec04
84 files changed
+19278 -3888

No files matched your search

+211 -27
View File
@@ -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 };
}
}