feat: kill conversations on off-platform and seller pushes
Seller detection on replies was a case-insensitive substring match over the configured keyword list, and every hit landed in `human-required` alongside genuine prospects that need a person. Off-platform pushes were only checked for r4r policy modes, as were the under-18 signals. Three layers now: - New `off-platform-signals` module matches on normalized text (unicode folding, zero-width stripping, leet mapping, separator removal), so `0nlyf4ns`, `t3l3gr4m` and `t e l e g r a m` are caught. Ambiguous terms need context: `snap` on its own stays a photo request, never a kill. - New terminal `killed` automation status. It records the evidence, sets the durable seller flag, supersedes the pending claim, and resolves triage to `done`, so killed conversations leave every actionable queue. Off-platform kills are independent of `reddit.filterSellers`. - Optional AI screening of inbound replies behind `conversations.aiReplyScreening` (off by default). Runs only after the deterministic gates pass, memoizes per inbound event, and fails open. Under-18 and in-person/contact-detail signals now apply to every policy mode rather than r4r only, and remain human handoffs rather than kills. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ac12a3146a
commit
4404ae75ff
18 files changed
+907
-31
No files matched your search
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ConversationAutomationService } from '../src/services/conversation-automation.js';
|
||||
import {
|
||||
ConversationAutomationService,
|
||||
type ConversationAutomationOptions,
|
||||
} from '../src/services/conversation-automation.js';
|
||||
import type { ConversationState } from '../src/services/conversation-state.js';
|
||||
import type { ActiveChat } from '../src/services/chat.js';
|
||||
|
||||
@@ -18,7 +21,9 @@ function harness(
|
||||
handle(conversationId: string, incomingKey: string, text: string): Promise<object>;
|
||||
getOutboundAttribution?(conversationId: string): { eventIds: string[]; texts: string[] };
|
||||
},
|
||||
options?: ConversationAutomationOptions,
|
||||
) {
|
||||
const triage = { setStatus: vi.fn() };
|
||||
const saved = new Map<string, ConversationState>();
|
||||
let lastSaved: ConversationState | undefined;
|
||||
const chat = {
|
||||
@@ -55,7 +60,7 @@ function harness(
|
||||
const rateLimiter = {
|
||||
throttle: vi.fn(async (operation: () => Promise<boolean>) => operation()),
|
||||
};
|
||||
const logger = { info: vi.fn() };
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), debug: 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,
|
||||
@@ -69,8 +74,9 @@ function harness(
|
||||
0,
|
||||
beforeSendLoader ?? (async (_conversationId, observed) => observed),
|
||||
mediaExchange as never,
|
||||
{ triage, ...options },
|
||||
);
|
||||
return { service, chat, ai, states, openers, claims, rateLimiter, getSaved: () => lastSaved };
|
||||
return { service, chat, ai, states, openers, claims, rateLimiter, triage, getSaved: () => lastSaved };
|
||||
}
|
||||
|
||||
function incoming(turn: number, text = `reply ${turn}`, media?: Array<{ originalUrl: string; localPath: string; fileName: string }>) {
|
||||
@@ -190,18 +196,147 @@ describe('conversation automation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('flags a potential upsell for human handoff without generating a reply', async () => {
|
||||
const { service, chat, ai, getSaved } = harness(['onlyfans'], ['fansly.com']);
|
||||
it('kills a conversation that pushes a seller funnel', async () => {
|
||||
const { service, chat, ai, triage, 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: 3 });
|
||||
expect(result).toMatchObject({ kind: 'killed', 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).',
|
||||
status: 'killed',
|
||||
handoffReason: 'Seller funnel detected (Fansly: "fansly").',
|
||||
});
|
||||
expect(getSaved()?.sellerDetected).toBe(true);
|
||||
expect(triage.setStatus).toHaveBeenCalledWith('room-alice', 'done', null, 1_000);
|
||||
});
|
||||
|
||||
it('kills a conversation that moves to another messaging app, whatever the policy mode', async () => {
|
||||
const { service, chat, triage, getSaved } = harness();
|
||||
const result = await service.processConversation(incoming(1, 'add me on telegram, im barely on here'));
|
||||
|
||||
expect(result).toMatchObject({ kind: 'killed', remainingTurns: 0 });
|
||||
expect(chat.sendChatMessage).not.toHaveBeenCalled();
|
||||
expect(getSaved()?.automation).toMatchObject({ status: 'killed', lastIncomingKey: 'in-1' });
|
||||
expect(getSaved()?.automation.handoffReason).toContain('Telegram');
|
||||
expect(triage.setStatus).toHaveBeenCalledWith('room-alice', 'done', null, 1_000);
|
||||
});
|
||||
|
||||
it('kills an obfuscated off-platform push', async () => {
|
||||
const { service, getSaved } = harness();
|
||||
const result = await service.processConversation(incoming(1, 'my t3l3gr4m is @jess1'));
|
||||
|
||||
expect(result?.kind).toBe('killed');
|
||||
expect(getSaved()?.automation.handoffReason).toContain('Telegram');
|
||||
});
|
||||
|
||||
it('does not treat a photo request as an off-platform push', async () => {
|
||||
const { service, chat } = harness();
|
||||
const result = await service.processConversation(incoming(1, 'send me a snap and ill send one back'));
|
||||
|
||||
expect(result?.kind).toBe('sent');
|
||||
expect(chat.sendChatMessage).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('leaves off-platform kills disabled when the operator turns them off', async () => {
|
||||
const { service, getSaved } = harness([], [], 0, undefined, undefined, undefined, { offPlatformKill: false });
|
||||
const result = await service.processConversation(incoming(1, 'my telegram is @jess'));
|
||||
|
||||
expect(result?.kind).toBe('sent');
|
||||
expect(getSaved()?.automation.status).toBe('active');
|
||||
});
|
||||
|
||||
it('hands off instead of killing when seller keywords are configured not to kill', async () => {
|
||||
const { service, triage, getSaved } = harness(['spoil me'], [], 0, undefined, undefined, undefined, {
|
||||
killOnSellerKeyword: false,
|
||||
});
|
||||
const result = await service.processConversation(incoming(1, 'you could spoil me a little'));
|
||||
|
||||
expect(result).toMatchObject({ kind: 'human-required', remainingTurns: 3 });
|
||||
expect(getSaved()?.automation).toMatchObject({
|
||||
status: 'human-required',
|
||||
handoffReason: 'Potential upsell detected (keyword: spoil me).',
|
||||
});
|
||||
expect(triage.setStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('kills on a high-confidence AI screening verdict', async () => {
|
||||
const screener = {
|
||||
screenConversationReply: vi.fn().mockResolvedValue({
|
||||
classification: 'seller', confidence: 0.93, evidence: 'everything good is behind her link',
|
||||
}),
|
||||
};
|
||||
const { service, chat, getSaved } = harness([], [], 0, undefined, undefined, undefined, {
|
||||
screener,
|
||||
screening: { enabled: true, killConfidence: 0.8, flagConfidence: 0.6 },
|
||||
});
|
||||
|
||||
const result = await service.processConversation(incoming(1, 'the good stuff is elsewhere babe'));
|
||||
|
||||
expect(result?.kind).toBe('killed');
|
||||
expect(chat.sendChatMessage).not.toHaveBeenCalled();
|
||||
expect(getSaved()?.automation.handoffReason).toBe(
|
||||
'AI screening classified this conversation as seller (confidence 0.93): everything good is behind her link',
|
||||
);
|
||||
});
|
||||
|
||||
it('hands off rather than killing on a mid-confidence AI screening verdict', async () => {
|
||||
const screener = {
|
||||
screenConversationReply: vi.fn().mockResolvedValue({
|
||||
classification: 'scam', confidence: 0.65, evidence: 'asked for a verification code',
|
||||
}),
|
||||
};
|
||||
const { service, triage, getSaved } = harness([], [], 0, undefined, undefined, undefined, {
|
||||
screener,
|
||||
screening: { enabled: true, killConfidence: 0.8, flagConfidence: 0.6 },
|
||||
});
|
||||
|
||||
const result = await service.processConversation(incoming(1, 'can you help me with something first'));
|
||||
|
||||
expect(result?.kind).toBe('human-required');
|
||||
expect(getSaved()?.automation.status).toBe('human-required');
|
||||
expect(triage.setStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replies as normal when screening returns a genuine verdict, and screens once per inbound event', async () => {
|
||||
const screener = {
|
||||
screenConversationReply: vi.fn().mockResolvedValue({
|
||||
classification: 'genuine', confidence: 0.99, evidence: 'answering the question asked',
|
||||
}),
|
||||
};
|
||||
const { service, chat } = harness([], [], 0, undefined, undefined, undefined, {
|
||||
screener,
|
||||
screening: { enabled: true, killConfidence: 0.8, flagConfidence: 0.6 },
|
||||
});
|
||||
|
||||
expect((await service.processConversation(incoming(1)))?.kind).toBe('sent');
|
||||
expect(chat.sendChatMessage).toHaveBeenCalledOnce();
|
||||
expect(screener.screenConversationReply).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('fails open and replies when the screener throws', async () => {
|
||||
const screener = {
|
||||
screenConversationReply: vi.fn().mockRejectedValue(new Error('openrouter unavailable')),
|
||||
};
|
||||
const { service, chat } = harness([], [], 0, undefined, undefined, undefined, {
|
||||
screener,
|
||||
screening: { enabled: true, killConfidence: 0.8, flagConfidence: 0.6 },
|
||||
});
|
||||
|
||||
expect((await service.processConversation(incoming(1)))?.kind).toBe('sent');
|
||||
expect(chat.sendChatMessage).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not call the screener at all when screening is disabled', async () => {
|
||||
const screener = { screenConversationReply: vi.fn() };
|
||||
const { service } = harness([], [], 0, undefined, undefined, undefined, {
|
||||
screener,
|
||||
screening: { enabled: false, killConfidence: 0.8, flagConfidence: 0.6 },
|
||||
});
|
||||
|
||||
await service.processConversation(incoming(1));
|
||||
|
||||
expect(screener.screenConversationReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('completes when an inbound image is received', async () => {
|
||||
|
||||
Reference in new issue
Block a user