import { describe, expect, it, vi } from 'vitest'; import { ConversationAutomationService } from '../src/services/conversation-automation.js'; import type { ConversationState } from '../src/services/conversation-state.js'; function harness( sellerKeywords: string[] = [], sellerDomains: string[] = [], openerSentAt: number | null = 0, subredditConversation?: { mode: 'legacy' | 'r4r' | 'manual'; maxAutomatedTurns: number }, ) { let saved: ConversationState | undefined; const chat = { refreshTopChats: vi.fn().mockResolvedValue(undefined), getCachedConversations: vi.fn().mockResolvedValue([]), sendChatMessage: vi.fn().mockResolvedValue(true), }; const ai = { generateMediaAsk: vi.fn().mockResolvedValue('Would you be comfortable sharing a selfie?'), generateReply: vi.fn().mockResolvedValue('That sounds fun. What got you into it?'), }; const states = { get: vi.fn(() => saved), save: vi.fn((_id: string, state: ConversationState) => { saved = structuredClone(state); }), }; const openers = { getByChatRoomId: vi.fn(() => openerSentAt === null ? undefined : { sentAt: openerSentAt, subreddit: 'r4r' }), }; const claims = { tryClaim: vi.fn(() => true), markSent: vi.fn(() => true), }; const rateLimiter = { throttle: vi.fn(async (operation: () => Promise) => operation()), }; const logger = { info: vi.fn() }; const service = new ConversationAutomationService( chat as never, ai as never, states as never, openers as never, claims, rateLimiter as never, logger as never, false, sellerKeywords, sellerDomains, () => subredditConversation ? [{ name: 'r4r', enabled: true, type: 'chat', tone: 'default', conversation: subredditConversation, }] : [], ); return { service, chat, ai, states, openers, claims, getSaved: () => saved }; } function incoming(turn: number, text = `reply ${turn}`, media?: Array<{ originalUrl: string; localPath: string; fileName: string }>) { return { username: 'alice', roomName: 'alice', roomId: 'room-alice', url: '/room/room-alice', lastMessageTime: turn, lastMessageSnippet: text, isFromUs: false, unread: true, messages: [ { eventId: `out-${turn}`, sender: 'me', text: 'hello', timestamp: turn * 2, isSelf: true }, { eventId: `in-${turn}`, sender: 'alice', text, timestamp: turn * 2 + 1, isSelf: false, media }, ], }; } describe('conversation automation', () => { it('sends once per inbound turn and flags human handoff after the third send', async () => { const { service, chat, getSaved } = harness(); expect((await service.processConversation(incoming(1)))?.remainingTurns).toBe(2); expect((await service.processConversation(incoming(2)))?.remainingTurns).toBe(1); const third = await service.processConversation(incoming(3)); expect(third).toMatchObject({ kind: 'human-required', remainingTurns: 0 }); expect(chat.sendChatMessage).toHaveBeenCalledTimes(3); expect(getSaved()?.automation).toMatchObject({ turnsUsed: 3, status: 'human-required' }); expect(getSaved()?.automation.aiMessageFingerprints).toHaveLength(3); expect(await service.processConversation(incoming(3))).toBeNull(); }); it('stops without sending when the user declines images', async () => { const { service, chat, getSaved } = harness(); const result = await service.processConversation(incoming(1, "No thanks, I don't send photos.")); expect(result?.kind).toBe('boundary'); expect(chat.sendChatMessage).not.toHaveBeenCalled(); expect(getSaved()?.automation.status).toBe('boundary'); }); it('flags a potential upsell for human handoff without generating a reply', async () => { const { service, chat, ai, getSaved } = harness(['onlyfans'], ['fansly.com']); const result = await service.processConversation(incoming(1, 'My Fansly is https://www.fansly.com/example')); expect(result).toMatchObject({ kind: 'human-required', remainingTurns: 0 }); expect(ai.generateMediaAsk).not.toHaveBeenCalled(); expect(chat.sendChatMessage).not.toHaveBeenCalled(); expect(getSaved()?.automation).toMatchObject({ status: 'human-required', handoffReason: 'Potential upsell detected (domain: fansly.com).', }); }); it('completes when an inbound image is received', async () => { const { service, chat, getSaved } = harness(); const result = await service.processConversation(incoming(1, '[Image]', [{ originalUrl: 'mxc://image', localPath: 'image.jpg', fileName: 'image.jpg', }])); expect(result?.kind).toBe('image-received'); expect(chat.sendChatMessage).not.toHaveBeenCalled(); expect(getSaved()?.automation.status).toBe('image-received'); }); it('does not generate or send after a human has overridden the thread', async () => { const { service, chat, ai, states } = harness(); const conversation = incoming(1); await service.processConversation(conversation); const state = states.save.mock.calls.at(-1)?.[1] as ConversationState; state.automation.status = 'user-overridden'; states.save('room-alice', state); const result = await service.processConversation(incoming(2)); expect(result).toBeNull(); expect(chat.sendChatMessage).toHaveBeenCalledTimes(1); expect(ai.generateMediaAsk).toHaveBeenCalledTimes(1); }); it('does not reply when the room has no recorded opener', async () => { const { service, chat, ai, claims } = harness([], [], null); expect(await service.processConversation(incoming(1))).toBeNull(); expect(ai.generateMediaAsk).not.toHaveBeenCalled(); expect(chat.sendChatMessage).not.toHaveBeenCalled(); expect(claims.tryClaim).not.toHaveBeenCalled(); }); it('does not reply to an inbound event older than the recorded opener', async () => { const { service, chat, ai, claims } = harness([], [], 4); expect(await service.processConversation(incoming(1))).toBeNull(); expect(ai.generateMediaAsk).not.toHaveBeenCalled(); expect(chat.sendChatMessage).not.toHaveBeenCalled(); expect(claims.tryClaim).not.toHaveBeenCalled(); }); it('claims an inbound event durably before sending and marks it sent afterwards', async () => { const { service, chat, claims } = harness(); await service.processConversation(incoming(1)); expect(claims.tryClaim).toHaveBeenCalledWith('room-alice', 'in-1'); expect(claims.tryClaim.mock.invocationCallOrder[0]) .toBeLessThan(chat.sendChatMessage.mock.invocationCallOrder[0]); expect(claims.markSent).toHaveBeenCalledWith('room-alice', 'in-1'); }); it('does not send when another worker already claimed the inbound event', async () => { const { service, chat, claims } = harness(); claims.tryClaim.mockReturnValue(false); expect(await service.processConversation(incoming(1))).toBeNull(); expect(chat.sendChatMessage).not.toHaveBeenCalled(); }); it('uses autonomous general replies and the configured turn limit for an r4r subreddit', async () => { const { service, ai, chat, getSaved } = harness([], [], 0, { mode: 'r4r', maxAutomatedTurns: 2 }); expect((await service.processConversation(incoming(1)))?.kind).toBe('sent'); const result = await service.processConversation(incoming(2)); expect(result).toMatchObject({ kind: 'human-required', remainingTurns: 0 }); expect(ai.generateReply).toHaveBeenCalledTimes(2); expect(ai.generateMediaAsk).not.toHaveBeenCalled(); expect(chat.sendChatMessage).toHaveBeenCalledTimes(2); expect(getSaved()?.automation).toMatchObject({ policyMode: 'r4r', policyVersion: 1, maxAutomatedTurns: 2, turnsUsed: 2, }); expect(getSaved()?.askedForMedia).toBe(false); }); it('snapshots the r4r policy so later configuration changes do not switch an active conversation', async () => { const behaviour: { mode: 'legacy' | 'r4r' | 'manual'; maxAutomatedTurns: number } = { mode: 'r4r', maxAutomatedTurns: 2, }; const { service, ai, getSaved } = harness([], [], 0, behaviour); await service.processConversation(incoming(1)); behaviour.mode = 'manual'; await service.processConversation(incoming(2)); expect(ai.generateReply).toHaveBeenCalledTimes(2); expect(getSaved()?.automation.policyMode).toBe('r4r'); }); it('marks manual conversations for human handling once without generating or sending', async () => { const { service, ai, chat, getSaved } = harness([], [], 0, { mode: 'manual', maxAutomatedTurns: 3 }); const result = await service.processConversation(incoming(1)); expect(result?.kind).toBe('human-required'); expect(getSaved()?.automation).toMatchObject({ policyMode: 'manual', status: 'human-required' }); expect(ai.generateReply).not.toHaveBeenCalled(); expect(ai.generateMediaAsk).not.toHaveBeenCalled(); expect(chat.sendChatMessage).not.toHaveBeenCalled(); expect(await service.processConversation(incoming(1))).toBeNull(); }); it('hands r4r conversations to a human when age or contact safety gates trigger', async () => { const underage = harness([], [], 0, { mode: 'r4r', maxAutomatedTurns: 5 }); const result = await underage.service.processConversation(incoming(1, "I'm 17, what about you?")); expect(result?.kind).toBe('human-required'); expect(underage.getSaved()?.automation.handoffReason).toBe('The participant may be under 18.'); expect(underage.chat.sendChatMessage).not.toHaveBeenCalled(); }); it('does not send an r4r generation that asks for media', async () => { const fixture = harness([], [], 0, { mode: 'r4r', maxAutomatedTurns: 5 }); fixture.ai.generateReply.mockResolvedValue('Would you send me a nude?'); const result = await fixture.service.processConversation(incoming(1)); expect(result?.kind).toBe('human-required'); expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled(); expect(fixture.getSaved()?.automation.handoffReason).toContain('failed autonomous safety checks'); }); });