feat: add Exotel webhook controller and service for call event parsing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 09:04:15 +05:30
parent 5b35c65e6e
commit 30df1d0158
7 changed files with 215 additions and 0 deletions

View File

@@ -0,0 +1,113 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Anthropic from '@anthropic-ai/sdk';
type LeadContext = {
firstName?: string;
lastName?: string;
leadSource?: string;
interestedService?: string;
leadStatus?: string;
contactAttempts?: number;
createdAt?: string;
campaignId?: string;
activities?: { activityType: string; summary: string }[];
};
type EnrichmentResult = {
aiSummary: string;
aiSuggestedAction: string;
};
@Injectable()
export class AiEnrichmentService {
private readonly logger = new Logger(AiEnrichmentService.name);
private client: Anthropic | null = null;
constructor(private config: ConfigService) {
const apiKey = config.get<string>('ai.anthropicApiKey');
if (apiKey) {
this.client = new Anthropic({ apiKey });
} else {
this.logger.warn('ANTHROPIC_API_KEY not set — AI enrichment disabled, using fallback');
}
}
async enrichLead(lead: LeadContext): Promise<EnrichmentResult> {
// Fallback if no API key configured
if (!this.client) {
return this.fallbackEnrichment(lead);
}
try {
const daysSince = lead.createdAt
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24))
: 0;
const activitiesText = lead.activities?.length
? lead.activities.map(a => `- ${a.activityType}: ${a.summary}`).join('\n')
: 'No previous interactions';
const prompt = `You are an AI assistant for a hospital call center at Global Hospital.
An inbound call is coming in from a lead. Summarize their history and suggest what the call center agent should do.
Lead details:
- Name: ${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}
- Source: ${lead.leadSource ?? 'Unknown'}
- Interested in: ${lead.interestedService ?? 'Unknown'}
- Current status: ${lead.leadStatus ?? 'Unknown'}
- Lead age: ${daysSince} days
- Contact attempts: ${lead.contactAttempts ?? 0}
Recent activity:
${activitiesText}
Respond ONLY with valid JSON (no markdown, no code blocks):
{"aiSummary": "1-2 sentence summary of who this lead is and their history", "aiSuggestedAction": "5-10 word suggested action for the agent"}`;
const response = await this.client.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 200,
messages: [{ role: 'user', content: prompt }],
});
const text = response.content[0].type === 'text' ? response.content[0].text : '';
const parsed = JSON.parse(text.trim());
this.logger.log(`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`);
return {
aiSummary: parsed.aiSummary,
aiSuggestedAction: parsed.aiSuggestedAction,
};
} catch (error) {
this.logger.error(`AI enrichment failed: ${error}`);
return this.fallbackEnrichment(lead);
}
}
private fallbackEnrichment(lead: LeadContext): EnrichmentResult {
const daysSince = lead.createdAt
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24))
: 0;
const attempts = lead.contactAttempts ?? 0;
const service = lead.interestedService ?? 'general inquiry';
const source = lead.leadSource?.replace(/_/g, ' ').toLowerCase() ?? 'unknown source';
let summary: string;
let action: string;
if (attempts === 0) {
summary = `First-time inquiry from ${source}. Interested in ${service}. Lead is ${daysSince} day(s) old with no previous contact.`;
action = `Introduce services and offer appointment booking`;
} else if (attempts === 1) {
summary = `Returning inquiry — contacted once before. Interested in ${service} via ${source}. Lead is ${daysSince} day(s) old.`;
action = `Follow up on previous conversation, offer appointment`;
} else {
summary = `Repeat contact (${attempts} previous attempts) for ${service}. Originally from ${source}, ${daysSince} day(s) old. Shows high interest.`;
action = `Prioritize appointment booking — high-intent lead`;
}
return { aiSummary: summary, aiSuggestedAction: action };
}
}

View File

@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import configuration from './config/configuration';
import { PlatformModule } from './platform/platform.module';
import { ExotelModule } from './exotel/exotel.module';
@Module({
imports: [
@@ -10,6 +11,7 @@ import { PlatformModule } from './platform/platform.module';
isGlobal: true,
}),
PlatformModule,
ExotelModule,
],
})
export class AppModule {}

View File

View File

@@ -0,0 +1,24 @@
import { Controller, Post, Body, Logger, HttpCode } from '@nestjs/common';
import { ExotelService } from './exotel.service';
import type { ExotelWebhookPayload } from './exotel.types';
@Controller('webhooks/exotel')
export class ExotelController {
private readonly logger = new Logger(ExotelController.name);
constructor(private readonly exotelService: ExotelService) {}
@Post('call-status')
@HttpCode(200)
async handleCallStatus(@Body() payload: ExotelWebhookPayload) {
this.logger.log(`Received Exotel webhook: ${payload.event_details?.event_type}`);
const callEvent = this.exotelService.parseWebhook(payload);
// TODO: Forward to CallEventsService (Task 4)
// For now, just log
this.logger.log(`Call event: ${JSON.stringify(callEvent)}`);
return { status: 'received' };
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ExotelController } from './exotel.controller';
import { ExotelService } from './exotel.service';
@Module({
controllers: [ExotelController],
providers: [ExotelService],
exports: [ExotelService],
})
export class ExotelModule {}

View File

@@ -0,0 +1,31 @@
import { Injectable, Logger } from '@nestjs/common';
import type { ExotelWebhookPayload, CallEvent } from './exotel.types';
@Injectable()
export class ExotelService {
private readonly logger = new Logger(ExotelService.name);
parseWebhook(payload: ExotelWebhookPayload): CallEvent {
const { event_details, call_details } = payload;
const eventType = event_details.event_type === 'answered' ? 'answered'
: event_details.event_type === 'terminal' ? 'ended'
: 'ringing';
const callEvent: CallEvent = {
exotelCallSid: call_details.call_sid,
eventType,
direction: call_details.direction,
callerPhone: call_details.customer_details?.number ?? '',
agentName: call_details.assigned_agent_details?.name ?? 'Unknown',
agentPhone: call_details.assigned_agent_details?.number ?? '',
duration: call_details.total_talk_time,
recordingUrl: call_details.recordings?.[0]?.url,
callStatus: call_details.call_status,
timestamp: new Date().toISOString(),
};
this.logger.log(`Parsed Exotel event: ${eventType} for call ${call_details.call_sid}`);
return callEvent;
}
}

View File

@@ -0,0 +1,35 @@
// Exotel webhook payload (from their API docs)
export type ExotelWebhookPayload = {
event_details: {
event_type: 'answered' | 'terminal';
};
call_details: {
call_sid: string;
direction: 'inbound' | 'outbound';
call_status?: string;
total_talk_time?: number;
assigned_agent_details?: {
name: string;
number: string;
};
customer_details?: {
number: string;
name?: string;
};
recordings?: { url: string }[];
};
};
// Internal call event (normalized)
export type CallEvent = {
exotelCallSid: string;
eventType: 'ringing' | 'answered' | 'ended';
direction: 'inbound' | 'outbound';
callerPhone: string;
agentName: string;
agentPhone: string;
duration?: number;
recordingUrl?: string;
callStatus?: string;
timestamp: string;
};