From e8f56b42e69bd552a98f91c78c094500a4902999 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sun, 9 Aug 2026 07:26:04 +0100 Subject: [PATCH] feat: hand off upsell conversations to humans --- .../conversations/conversation-detail.tsx | 2 +- src/services/conversation-automation.ts | 14 ++++++++++++++ src/services/reddit-account-runtime-builder.ts | 2 ++ tests/conversation-automation.test.ts | 16 +++++++++++++++- 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/src/features/conversations/conversation-detail.tsx b/apps/dashboard/src/features/conversations/conversation-detail.tsx index 2bfffda..60dbc5d 100644 --- a/apps/dashboard/src/features/conversations/conversation-detail.tsx +++ b/apps/dashboard/src/features/conversations/conversation-detail.tsx @@ -163,7 +163,7 @@ function automationStatusMessage(conversation: ConversationDetailResponse): stri case 'image-received': return 'Image received. Automation is complete.'; case 'human-required': - return 'Automated turns are exhausted. A human should take over.'; + return conversation.automation.handoffReason ?? 'Automated turns are exhausted. A human should take over.'; case 'boundary': return 'Automation stopped because the user set a boundary.'; case 'user-overridden': diff --git a/src/services/conversation-automation.ts b/src/services/conversation-automation.ts index 2a142d3..40cefe5 100644 --- a/src/services/conversation-automation.ts +++ b/src/services/conversation-automation.ts @@ -2,6 +2,7 @@ import type { OpenRouterClient } from '../ai/openrouter.js'; import type { ConversationStateRepository } from '../database/repositories.js'; import type { Logger } from '../utils/logger.js'; import type { RateLimiter } from '../utils/rate-limiter.js'; +import { findSellerSignal } from '../reddit/api.js'; import type { ActiveChat, ChatMessage } from './chat/types.js'; import type { ChatService } from './chat/service.js'; import { @@ -40,6 +41,8 @@ export class ConversationAutomationService { private readonly rateLimiter: RateLimiter, private readonly logger: Logger, private readonly dryRun = false, + private readonly sellerKeywords: readonly string[] = [], + private readonly sellerDomains: readonly string[] = [], ) {} async processUnreadConversations(signal?: AbortSignal): Promise { @@ -72,6 +75,17 @@ export class ConversationAutomationService { if (state.automation.lastIncomingKey === incomingKey) return null; if (state.automation.status !== 'active') return null; + const sellerSignal = findSellerSignal(latest.text, [...this.sellerKeywords], [...this.sellerDomains]); + if (sellerSignal) { + return this.finish( + conversation, + conversationId, + state, + incomingKey, + 'human-required', + `Potential upsell detected (${sellerSignal.kind}: ${sellerSignal.value}).`, + ); + } if (hasReceivedImage(messages)) { return this.finish(conversation, conversationId, state, incomingKey, 'image-received', 'The user shared an image.'); } diff --git a/src/services/reddit-account-runtime-builder.ts b/src/services/reddit-account-runtime-builder.ts index 4fa3e5e..3ca7dc9 100644 --- a/src/services/reddit-account-runtime-builder.ts +++ b/src/services/reddit-account-runtime-builder.ts @@ -161,6 +161,8 @@ async function buildRuntime( rateLimiter, logger, accountConfig.browser.dryRun, + accountConfig.reddit.filterSellers ? accountConfig.reddit.sellerKeywords : [], + accountConfig.reddit.filterSellers ? accountConfig.reddit.sellerDomains : [], ); const resources: RedditAccountRuntimeResources = { diff --git a/tests/conversation-automation.test.ts b/tests/conversation-automation.test.ts index 30cb23e..4d83df0 100644 --- a/tests/conversation-automation.test.ts +++ b/tests/conversation-automation.test.ts @@ -2,7 +2,7 @@ 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() { +function harness(sellerKeywords: string[] = [], sellerDomains: string[] = []) { let saved: ConversationState | undefined; const chat = { refreshTopChats: vi.fn().mockResolvedValue(undefined), @@ -20,6 +20,7 @@ function harness() { const logger = { info: vi.fn() }; const service = new ConversationAutomationService( chat as never, ai as never, states as never, rateLimiter as never, logger as never, + false, sellerKeywords, sellerDomains, ); return { service, chat, ai, states, getSaved: () => saved }; } @@ -59,6 +60,19 @@ describe('conversation automation', () => { 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]', [{