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

+704
View File
@@ -0,0 +1,704 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type OpenAI from 'openai';
import { OpenRouterClient, stripDashes } from '../src/ai/openrouter.js';
import { createMockLogger } from '../tests/mockLogger.js';
import { getTestConfig } from '../tests/testUtils.js';
describe('OpenRouterClient', () => {
let client: OpenRouterClient;
let logger: ReturnType<typeof createMockLogger>;
let mockCreate: ReturnType<typeof vi.fn>;
beforeEach(() => {
logger = createMockLogger();
const config = getTestConfig({
openrouter: {
apiKey: 'test-key',
imageAnalysisModel: 'google/gemini-flash',
imageAnalysisFallbackModels: ['fallback-1'],
chatModel: 'chat-model',
chatFallbackModels: [],
imageAnalysisPrompt: 'Analyze this image in detail.',
messageGenerationPrompt: 'Write a message for {author} about "{title}". Analysis: {analysis}',
},
});
mockCreate = vi.fn();
const mockOpenAI = {
chat: { completions: { create: mockCreate } },
} as unknown as OpenAI;
client = new OpenRouterClient(config, logger, mockOpenAI);
});
describe('isRefusal (internal)', () => {
// We test refusal detection by checking the analyzeImage behavior
// since isRefusal is private. We do this by mocking responses that
// contain refusal patterns.
it('detects "I cannot fulfill" as refusal', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: "I cannot fulfill this request" } }],
});
await expect(
client.analyzeImage('base64data', 'image/jpeg')
).rejects.toThrow('AI_REFUSAL');
});
it('detects "I can\'t provide" as refusal', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: "I can't provide that analysis" } }],
});
await expect(
client.analyzeImage('base64data', 'image/jpeg')
).rejects.toThrow('AI_REFUSAL');
});
it('detects "my safety guidelines" as refusal', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: "This violates my safety guidelines" } }],
});
await expect(
client.analyzeImage('base64data', 'image/jpeg')
).rejects.toThrow('AI_REFUSAL');
});
it('detects "I\'m unable to" as refusal', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: "I'm unable to analyze this" } }],
});
await expect(
client.analyzeImage('base64data', 'image/jpeg')
).rejects.toThrow('AI_REFUSAL');
});
it('detects "sexually explicit" as refusal', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: "This is sexually explicit content" } }],
});
await expect(
client.analyzeImage('base64data', 'image/jpeg')
).rejects.toThrow('AI_REFUSAL');
});
it('detects "not appropriate for me" as refusal', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: "This is not appropriate for me" } }],
});
await expect(
client.analyzeImage('base64data', 'image/jpeg')
).rejects.toThrow('AI_REFUSAL');
});
it('does NOT treat normal responses as refusal', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'GENDER:female\n\nThe image shows a woman with blonde hair standing on a beach.' } }],
});
const result = await client.analyzeImage('base64data', 'image/jpeg');
expect(result.detectedGender).toBe('female');
expect(result.description).toBe('The image shows a woman with blonde hair standing on a beach.');
});
});
describe('analyzeImage - gender parsing', () => {
it('parses GENDER:female from response', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'GENDER:female\n\nBeautiful woman with long brown hair.' } }],
});
const result = await client.analyzeImage('base64data', 'image/jpeg');
expect(result.detectedGender).toBe('female');
expect(result.description).toBe('Beautiful woman with long brown hair.');
});
it('parses GENDER:male from response', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'GENDER: male\n\nA man in a gym.' } }],
});
const result = await client.analyzeImage('base64data', 'image/jpeg');
expect(result.detectedGender).toBe('male');
});
it('parses GENDER:other from response', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'GENDER:other\n\nGroup of people.' } }],
});
const result = await client.analyzeImage('base64data', 'image/jpeg');
expect(result.detectedGender).toBe('other');
});
it('parses GENDER:unknown from response', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'GENDER:unknown\n\nImage too dark to tell.' } }],
});
const result = await client.analyzeImage('base64data', 'image/jpeg');
expect(result.detectedGender).toBe('unknown');
});
it('defaults to unknown when no GENDER prefix', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'A person in a photo.' } }],
});
const result = await client.analyzeImage('base64data', 'image/jpeg');
expect(result.detectedGender).toBe('unknown');
expect(result.description).toBe('A person in a photo.');
});
it('strips GENDER line from description', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'GENDER:female\n\nTall and fit with blue eyes.\n\nAdditional details here.' } }],
});
const result = await client.analyzeImage('base64data', 'image/jpeg');
expect(result.description).toBe('Tall and fit with blue eyes.\n\nAdditional details here.');
});
it('parses RATING:<n> on the 1-100 scale', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'GENDER:female\nRATING:73\n\nDescription here.' } }],
});
const result = await client.analyzeImage('base64data', 'image/jpeg');
expect(result.attractivenessScore).toBe(73);
expect(result.description).toBe('Description here.');
});
it('returns null attractivenessScore when no RATING line', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'GENDER:female\n\nNo rating here.' } }],
});
const result = await client.analyzeImage('base64data', 'image/jpeg');
expect(result.attractivenessScore).toBeNull();
});
it('parses RATING when wrapped in markdown bold', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'GENDER:female\n**RATING:** 63\nREASON: cute face.\n\nDescription here.' } }],
});
const result = await client.analyzeImage('base64data', 'image/jpeg');
expect(result.attractivenessScore).toBe(63);
expect(result.description).not.toMatch(/RATING/i);
});
it('parses RATING when the number itself is bolded', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'GENDER:female\nRATING: **78**\n\nDescription here.' } }],
});
const result = await client.analyzeImage('base64data', 'image/jpeg');
expect(result.attractivenessScore).toBe(78);
});
it('rejects out-of-range RATING values', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'GENDER:female\nRATING:250\n\nBad rating.' } }],
});
const result = await client.analyzeImage('base64data', 'image/jpeg');
expect(result.attractivenessScore).toBeNull();
});
it('handles case-insensitive GENDER prefix', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'GENDER:FEMALE\n\nA woman.' } }],
});
const result = await client.analyzeImage('base64data', 'image/jpeg');
expect(result.detectedGender).toBe('female');
});
});
describe('analyzeImage - fallback models', () => {
it('tries fallback model when primary refuses', async () => {
// First call refuses, second succeeds
mockCreate
.mockResolvedValueOnce({ choices: [{ message: { content: 'I cannot fulfill this request' } }] })
.mockResolvedValueOnce({
choices: [{ message: { content: 'GENDER:female\n\nFallback succeeded.' } }],
});
const result = await client.analyzeImage('base64data', 'image/jpeg');
expect(result.detectedGender).toBe('female');
expect(mockCreate).toHaveBeenCalledTimes(2);
});
it('throws AI_REFUSAL when all models refuse', async () => {
mockCreate
.mockResolvedValueOnce({ choices: [{ message: { content: 'I cannot fulfill this request' } }] })
.mockResolvedValueOnce({ choices: [{ message: { content: 'I cannot provide that either' } }] });
await expect(client.analyzeImage('base64data', 'image/jpeg'))
.rejects.toThrow('AI_REFUSAL: All models refused to analyze image');
expect(mockCreate).toHaveBeenCalledTimes(2);
});
it('throws AI_REFUSAL when primary throws API error and fallback refuses', async () => {
mockCreate
.mockRejectedValueOnce(new Error('API error'))
.mockResolvedValueOnce({ choices: [{ message: { content: 'I cannot fulfill that' } }] });
await expect(client.analyzeImage('base64data', 'image/jpeg'))
.rejects.toThrow('AI_REFUSAL');
});
it('logs fallback usage', async () => {
mockCreate
.mockResolvedValueOnce({ choices: [{ message: { content: 'I cannot fulfill this' } }] })
.mockResolvedValueOnce({
choices: [{ message: { content: 'GENDER:unknown\n\nFallback worked.' } }],
});
await client.analyzeImage('base64data', 'image/jpeg');
expect(logger.logs.some(l => l.message.includes('Fallback model'))).toBe(true);
});
});
describe('analyzeImage - error handling', () => {
it('throws when all models throw errors', async () => {
mockCreate.mockRejectedValue(new Error('API connection failed'));
await expect(client.analyzeImage('base64data', 'image/jpeg'))
.rejects.toThrow('API connection failed');
});
it('handles empty response content', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: '' } }],
});
const result = await client.analyzeImage('base64data', 'image/jpeg');
expect(result.detectedGender).toBe('unknown');
expect(result.description).toBe('');
});
it('handles undefined response content', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: undefined as unknown as string } }],
});
const result = await client.analyzeImage('base64data', 'image/jpeg');
expect(result.detectedGender).toBe('unknown');
});
});
describe('detectGenderOnly', () => {
it('returns female for "female" response', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'female' } }],
});
const result = await client.detectGenderOnly('base64data', 'image/jpeg');
expect(result).toBe('female');
});
it('returns male for "male" response', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'male' } }],
});
const result = await client.detectGenderOnly('base64data', 'image/jpeg');
expect(result).toBe('male');
});
it('returns other for "other" response', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'other' } }],
});
const result = await client.detectGenderOnly('base64data', 'image/jpeg');
expect(result).toBe('other');
});
it('returns unknown for empty response', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: '' } }],
});
const result = await client.detectGenderOnly('base64data', 'image/jpeg');
expect(result).toBe('unknown');
});
it('handles API errors gracefully', async () => {
mockCreate.mockRejectedValue(new Error('API error'));
const result = await client.detectGenderOnly('base64data', 'image/jpeg');
expect(result).toBe('unknown');
});
it('handles malformed response', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: null as unknown as string } }],
});
const result = await client.detectGenderOnly('base64data', 'image/jpeg');
expect(result).toBe('unknown');
});
});
describe('detectUpsell', () => {
it('detects OnlyFans keyword', () => {
const result = client.detectUpsell(
[{ text: 'Check out my OnlyFans!', isSelf: false }],
['onlyfans']
);
expect(result.isUpsell).toBe(true);
expect(result.matchedKeyword).toBe('onlyfans');
});
it('detects case-insensitive keyword', () => {
const result = client.detectUpsell(
[{ text: 'Visit my FANSLY page', isSelf: false }],
['fansly']
);
expect(result.isUpsell).toBe(true);
});
it('ignores own messages', () => {
const result = client.detectUpsell(
[{ text: 'Check out my OnlyFans!', isSelf: true }],
['onlyfans']
);
expect(result.isUpsell).toBe(false);
});
it('returns false when no keywords match', () => {
const result = client.detectUpsell(
[{ text: 'Hey, how are you?', isSelf: false }],
['onlyfans', 'fansly']
);
expect(result.isUpsell).toBe(false);
expect(result.matchedKeyword).toBeUndefined();
});
it('returns false for empty messages', () => {
const result = client.detectUpsell([], ['onlyfans']);
expect(result.isUpsell).toBe(false);
});
it('handles mixed sender messages', () => {
const result = client.detectUpsell([
{ text: 'Hey there!', isSelf: true },
{ text: 'Thanks! Check my OnlyFans', isSelf: false },
{ text: 'Oh nice', isSelf: true },
], ['onlyfans']);
expect(result.isUpsell).toBe(true);
});
it('detects multiple possible keywords (returns first match)', () => {
const result = client.detectUpsell(
[{ text: 'OnlyFans and Fansly', isSelf: false }],
['onlyfans', 'fansly']
);
expect(result.isUpsell).toBe(true);
// First matching keyword in the array wins
});
it('handles partial keyword matching', () => {
const result = client.detectUpsell(
[{ text: 'Send me cashapp', isSelf: false }],
['cashapp']
);
expect(result.isUpsell).toBe(true);
});
});
describe('generateMessage - prompt assembly', () => {
it('substitutes analysis, title, and author into prompt', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'Hey gorgeous!' } }],
});
await client.generateMessage(
'Blonde woman, rating 8/10',
'My beach selfie',
'beachgirl123',
[],
undefined
);
const callArgs = mockCreate.mock.calls[0][0];
const prompt = callArgs.messages[0].content as string;
expect(prompt).toContain('Blonde woman, rating 8/10');
expect(prompt).toContain('My beach selfie');
expect(prompt).toContain('beachgirl123');
});
it('appends strategy prompt override', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'Hey!' } }],
});
await client.generateMessage(
'Analysis',
'Title',
'Author',
[],
'Be direct and short.'
);
const callArgs = mockCreate.mock.calls[0][0];
const prompt = callArgs.messages[0].content as string;
expect(prompt).toContain('Be direct and short.');
});
it('includes successful examples in prompt', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'Hey!' } }],
});
await client.generateMessage(
'Analysis',
'Title',
'Author',
['Example 1: Hey you look great', 'Example 2: Love your pics'],
undefined
);
const callArgs = mockCreate.mock.calls[0][0];
const prompt = callArgs.messages[0].content as string;
expect(prompt).toContain('Example 1:');
expect(prompt).toContain('Example 2:');
expect(prompt).toContain('Hey you look great');
expect(prompt).toContain('positive responses');
});
it('substitutes {rating} placeholder when score provided', async () => {
const cfg = getTestConfig({
openrouter: {
apiKey: 'test-key',
imageAnalysisModel: 'google/gemini-flash',
imageAnalysisFallbackModels: [],
chatModel: 'chat-model',
chatFallbackModels: [],
imageAnalysisPrompt: 'analyze',
messageGenerationPrompt: 'Rating: {rating}. {analysis}',
},
});
const localClient = new OpenRouterClient(cfg, logger, { chat: { completions: { create: mockCreate } } } as unknown as OpenAI);
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'Hey!' } }],
});
await localClient.generateMessage('desc', 't', 'a', [], undefined, 73);
const prompt = mockCreate.mock.calls[0][0].messages[0].content as string;
expect(prompt).toContain('Rating: 73');
});
it('substitutes {rating} with "unknown" when score is null', async () => {
const cfg = getTestConfig({
openrouter: {
apiKey: 'test-key',
imageAnalysisModel: 'google/gemini-flash',
imageAnalysisFallbackModels: [],
chatModel: 'chat-model',
chatFallbackModels: [],
imageAnalysisPrompt: 'analyze',
messageGenerationPrompt: 'Rating: {rating}. {analysis}',
},
});
const localClient = new OpenRouterClient(cfg, logger, { chat: { completions: { create: mockCreate } } } as unknown as OpenAI);
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'Hey!' } }],
});
await localClient.generateMessage('desc', 't', 'a', [], undefined, null);
const prompt = mockCreate.mock.calls[0][0].messages[0].content as string;
expect(prompt).toContain('Rating: unknown');
});
it('sets correct model and max_tokens', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'Hey!' } }],
});
await client.generateMessage('Analysis', 'Title', 'Author', [], undefined);
const callArgs = mockCreate.mock.calls[0][0];
expect(callArgs.model).toBe('chat-model');
expect(callArgs.max_tokens).toBe(300);
expect(callArgs.temperature).toBe(0.8);
});
it('returns trimmed message', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: ' Hey there! ' } }],
});
const result = await client.generateMessage('Analysis', 'Title', 'Author', [], undefined);
expect(result).toBe('Hey there!');
});
it('handles empty message response', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: '' } }],
});
const result = await client.generateMessage('Analysis', 'Title', 'Author', [], undefined);
expect(result).toBe('');
});
it('handles undefined message response', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: undefined as unknown as string } }],
});
const result = await client.generateMessage('Analysis', 'Title', 'Author', [], undefined);
expect(result).toBe('');
});
});
describe('generateReply', () => {
it('includes conversation history in prompt', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'Thanks!' } }],
});
await client.generateReply(
[
{ sender: 'You', text: 'Hey gorgeous!', isSelf: true },
{ sender: 'user123', text: 'Aww thanks!', isSelf: false },
],
'user123'
);
const callArgs = mockCreate.mock.calls[0][0];
const prompt = callArgs.messages[0].content as string;
expect(prompt).toContain('Hey gorgeous!');
expect(prompt).toContain('Aww thanks!');
expect(prompt).toContain('user123');
});
it('uses chat model with correct params', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'Reply' } }],
});
await client.generateReply([], 'user');
const callArgs = mockCreate.mock.calls[0][0];
expect(callArgs.model).toBe('chat-model');
expect(callArgs.max_tokens).toBe(300);
expect(callArgs.temperature).toBe(0.8);
});
});
describe('analyzeAndGenerateMessage', () => {
it('calls analyzeImage then generateMessage', async () => {
mockCreate
.mockResolvedValueOnce({
choices: [{ message: { content: 'GENDER:female\n\nBlonde woman.' } }],
})
.mockResolvedValueOnce({
choices: [{ message: { content: 'Hey beautiful!' } }],
});
const result = await client.analyzeAndGenerateMessage(
'base64data',
'image/jpeg',
'My selfie',
'selfiegirl'
);
expect(result.analysis.detectedGender).toBe('female');
expect(result.analysis.description).toBe('Blonde woman.');
expect(result.message).toBe('Hey beautiful!');
expect(mockCreate).toHaveBeenCalledTimes(2);
});
});
describe('generateMediaAsk', () => {
it('builds a prompt including the conversation history and recipient username', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'bet you wont send a pic of that' } }],
});
const result = await client.generateMediaAsk(
[
{ sender: 'them', text: 'just got out of the shower', isSelf: false },
{ sender: 'me', text: 'lucky you', isSelf: true },
{ sender: 'them', text: 'haha yeah', isSelf: false },
],
'them',
);
expect(result).toBe('bet you wont send a pic of that');
expect(mockCreate).toHaveBeenCalledTimes(1);
const prompt = mockCreate.mock.calls[0][0].messages[0].content as string;
expect(prompt).toContain('them: just got out of the shower');
expect(prompt).toContain('You: lucky you');
expect(prompt).toContain('send to them');
expect(prompt).toMatch(/pic or video/i);
});
it('falls back to next model when primary refuses', async () => {
const config = getTestConfig({
openrouter: {
apiKey: 'test-key',
imageAnalysisModel: 'google/gemini-flash',
imageAnalysisFallbackModels: ['fallback-1'],
chatModel: 'chat-model',
chatFallbackModels: ['chat-fallback'],
imageAnalysisPrompt: 'Analyze this image in detail.',
messageGenerationPrompt: 'Write a message.',
},
});
const mockOpenAI = { chat: { completions: { create: mockCreate } } } as unknown as OpenAI;
const c = new OpenRouterClient(config, logger, mockOpenAI);
mockCreate
.mockResolvedValueOnce({ choices: [{ message: { content: 'I cannot generate that' } }] })
.mockResolvedValueOnce({ choices: [{ message: { content: 'show me 😏' } }] });
const result = await c.generateMediaAsk(
[{ sender: 'them', text: 'hi', isSelf: false }],
'them',
);
expect(result).toBe('show me 😏');
expect(mockCreate).toHaveBeenCalledTimes(2);
});
it('throws when the model errors', async () => {
mockCreate.mockRejectedValueOnce(new Error('network'));
await expect(
client.generateMediaAsk([{ sender: 'them', text: 'hi', isSelf: false }], 'them'),
).rejects.toThrow('network');
});
});
});
describe('stripDashes', () => {
it('replaces em dash with comma', () => {
expect(stripDashes('32M UK — bored tonight')).toBe('32M UK, bored tonight');
});
it('replaces en dash with comma', () => {
expect(stripDashes('hey – what are you into?')).toBe('hey, what are you into?');
});
it('collapses comma when dash sits before a full stop', () => {
expect(stripDashes('your post caught my eye — .')).toBe('your post caught my eye.');
});
it('leaves clean text untouched', () => {
expect(stripDashes('33M from the UK, your vibe is my type.')).toBe('33M from the UK, your vibe is my type.');
});
it('handles multiple dashes', () => {
expect(stripDashes('a — b — c')).toBe('a, b, c');
});
});