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':
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':
+14
View File
@@ -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<ConversationAutomationResult[]> {
@@ -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.');
}
@@ -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 = {
+15 -1
View File
@@ -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]', [{