feat: hand off upsell conversations to humans

This commit is contained in:
jjbrunton committed 2026-08-09 07:26:04 +01:00
1 parent 5ad7f1bf77
commit e8f56b42e6
4 files changed
+32 -2

No files matched your search

@@ -163,7 +163,7 @@ function automationStatusMessage(conversation: ConversationDetailResponse): stri
case 'image-received': case 'image-received':
return 'Image received. Automation is complete.'; return 'Image received. Automation is complete.';
case 'human-required': 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': case 'boundary':
return 'Automation stopped because the user set a boundary.'; return 'Automation stopped because the user set a boundary.';
case 'user-overridden': case 'user-overridden':
+14
View File
@@ -2,6 +2,7 @@ import type { OpenRouterClient } from '../ai/openrouter.js';
import type { ConversationStateRepository } from '../database/repositories.js'; import type { ConversationStateRepository } from '../database/repositories.js';
import type { Logger } from '../utils/logger.js'; import type { Logger } from '../utils/logger.js';
import type { RateLimiter } from '../utils/rate-limiter.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 { ActiveChat, ChatMessage } from './chat/types.js';
import type { ChatService } from './chat/service.js'; import type { ChatService } from './chat/service.js';
import { import {
@@ -40,6 +41,8 @@ export class ConversationAutomationService {
private readonly rateLimiter: RateLimiter, private readonly rateLimiter: RateLimiter,
private readonly logger: Logger, private readonly logger: Logger,
private readonly dryRun = false, private readonly dryRun = false,
private readonly sellerKeywords: readonly string[] = [],
private readonly sellerDomains: readonly string[] = [],
) {} ) {}
async processUnreadConversations(signal?: AbortSignal): Promise<ConversationAutomationResult[]> { async processUnreadConversations(signal?: AbortSignal): Promise<ConversationAutomationResult[]> {
@@ -72,6 +75,17 @@ export class ConversationAutomationService {
if (state.automation.lastIncomingKey === incomingKey) return null; if (state.automation.lastIncomingKey === incomingKey) return null;
if (state.automation.status !== 'active') 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)) { if (hasReceivedImage(messages)) {
return this.finish(conversation, conversationId, state, incomingKey, 'image-received', 'The user shared an image.'); return this.finish(conversation, conversationId, state, incomingKey, 'image-received', 'The user shared an image.');
} }
@@ -161,6 +161,8 @@ async function buildRuntime(
rateLimiter, rateLimiter,
logger, logger,
accountConfig.browser.dryRun, accountConfig.browser.dryRun,
accountConfig.reddit.filterSellers ? accountConfig.reddit.sellerKeywords : [],
accountConfig.reddit.filterSellers ? accountConfig.reddit.sellerDomains : [],
); );
const resources: RedditAccountRuntimeResources = { const resources: RedditAccountRuntimeResources = {
+15 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import { ConversationAutomationService } from '../src/services/conversation-automation.js'; import { ConversationAutomationService } from '../src/services/conversation-automation.js';
import type { ConversationState } from '../src/services/conversation-state.js'; import type { ConversationState } from '../src/services/conversation-state.js';
function harness() { function harness(sellerKeywords: string[] = [], sellerDomains: string[] = []) {
let saved: ConversationState | undefined; let saved: ConversationState | undefined;
const chat = { const chat = {
refreshTopChats: vi.fn().mockResolvedValue(undefined), refreshTopChats: vi.fn().mockResolvedValue(undefined),
@@ -20,6 +20,7 @@ function harness() {
const logger = { info: vi.fn() }; const logger = { info: vi.fn() };
const service = new ConversationAutomationService( const service = new ConversationAutomationService(
chat as never, ai as never, states as never, rateLimiter as never, logger as never, 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 }; return { service, chat, ai, states, getSaved: () => saved };
} }
@@ -59,6 +60,19 @@ describe('conversation automation', () => {
expect(getSaved()?.automation.status).toBe('boundary'); 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 () => { it('completes when an inbound image is received', async () => {
const { service, chat, getSaved } = harness(); const { service, chat, getSaved } = harness();
const result = await service.processConversation(incoming(1, '[Image]', [{ const result = await service.processConversation(incoming(1, '[Image]', [{