Files
RedditBot/tests/conversation-automation.test.ts
T

301 lines
13 KiB
TypeScript

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' | 'r4r-media' | 'manual';
maxAutomatedTurns: number;
rapportTurnsBeforeMediaAsk?: 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?'),
generateR4RMediaAsk: 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<boolean>) => 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,
rapportTurnsBeforeMediaAsk: subredditConversation.rapportTurnsBeforeMediaAsk ?? 3,
},
}] : [],
);
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: 2, 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' | 'r4r-media' | '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');
});
it('builds rapport before making exactly one optional media ask in hybrid mode', async () => {
const fixture = harness([], [], 0, {
mode: 'r4r-media', maxAutomatedTurns: 6, rapportTurnsBeforeMediaAsk: 2,
});
await fixture.service.processConversation(incoming(1));
await fixture.service.processConversation(incoming(2));
const mediaAsk = await fixture.service.processConversation(incoming(3));
await fixture.service.processConversation(incoming(4));
expect(mediaAsk?.kind).toBe('sent');
expect(fixture.ai.generateReply).toHaveBeenCalledTimes(3);
expect(fixture.ai.generateR4RMediaAsk).toHaveBeenCalledTimes(1);
expect(fixture.ai.generateMediaAsk).not.toHaveBeenCalled();
expect(fixture.chat.sendChatMessage).toHaveBeenCalledTimes(4);
expect(fixture.getSaved()?.askedForMedia).toBe(true);
expect(fixture.getSaved()?.automation).toMatchObject({
policyMode: 'r4r-media',
policyVersion: 2,
rapportTurnsBeforeMediaAsk: 2,
hybridPhase: 'post-media-ask',
turnsUsed: 4,
});
});
it('stops the hybrid flow when the media ask is declined', async () => {
const fixture = harness([], [], 0, {
mode: 'r4r-media', maxAutomatedTurns: 6, rapportTurnsBeforeMediaAsk: 1,
});
await fixture.service.processConversation(incoming(1));
await fixture.service.processConversation(incoming(2));
const result = await fixture.service.processConversation(incoming(3, "No thanks, I don't send photos."));
expect(result?.kind).toBe('boundary');
expect(fixture.chat.sendChatMessage).toHaveBeenCalledTimes(2);
expect(fixture.ai.generateR4RMediaAsk).toHaveBeenCalledTimes(1);
});
it('can record an image received after the media ask consumes the final turn', async () => {
const fixture = harness([], [], 0, {
mode: 'r4r-media', maxAutomatedTurns: 2, rapportTurnsBeforeMediaAsk: 1,
});
await fixture.service.processConversation(incoming(1));
const mediaAsk = await fixture.service.processConversation(incoming(2));
const result = await fixture.service.processConversation(incoming(3, '[Image]', [{
originalUrl: 'mxc://image', localPath: 'image.jpg', fileName: 'image.jpg',
}]));
expect(mediaAsk).toMatchObject({ kind: 'sent', remainingTurns: 0 });
expect(result?.kind).toBe('image-received');
expect(fixture.getSaved()?.automation.status).toBe('image-received');
});
it('rejects an unsafe hybrid media generation without sending it', async () => {
const fixture = harness([], [], 0, {
mode: 'r4r-media', maxAutomatedTurns: 4, rapportTurnsBeforeMediaAsk: 1,
});
fixture.ai.generateR4RMediaAsk.mockResolvedValue('Send me a nude.');
await fixture.service.processConversation(incoming(1));
const result = await fixture.service.processConversation(incoming(2));
expect(result?.kind).toBe('human-required');
expect(fixture.chat.sendChatMessage).toHaveBeenCalledTimes(1);
});
});