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:
Jamie BruntonandClaude Opus 5 committed 2026-08-10 12:53:00 +01:00
1 parent ac12a3146a
commit 4404ae75ff
18 files changed
+907 -31

No files matched your search

+143 -8
View File
@@ -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 () => {