From bbf77ed0e9ca5c5351ba4659dfd41945a64a02df Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Sat, 21 Mar 2026 10:36:35 +0530 Subject: [PATCH] feat: call control, recording, CDR, missed calls, live call assist - Call Control API (CONFERENCE/HOLD/MUTE/KICK_CALL) - Recording pause/unpause - Fetch CDR Detailed (call history with recordings) - Abandon Calls (missed calls from Ozonetel) - Call Assist WebSocket gateway (Deepgram STT + OpenAI suggestions) - Call Assist service (lead context loading) Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 13 ++ package.json | 1 + src/app.module.ts | 2 + src/call-assist/call-assist.gateway.ts | 136 ++++++++++++++++++ src/call-assist/call-assist.module.ts | 10 ++ src/call-assist/call-assist.service.ts | 123 +++++++++++++++++ src/ozonetel/ozonetel-agent.controller.ts | 68 ++++++++- src/ozonetel/ozonetel-agent.service.ts | 159 ++++++++++++++++++++++ 8 files changed, 511 insertions(+), 1 deletion(-) create mode 100644 src/call-assist/call-assist.gateway.ts create mode 100644 src/call-assist/call-assist.module.ts create mode 100644 src/call-assist/call-assist.service.ts diff --git a/package-lock.json b/package-lock.json index 1a14937..4e2dfd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@ai-sdk/anthropic": "^3.0.58", "@ai-sdk/openai": "^3.0.41", + "@deepgram/sdk": "^5.0.0", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.0.1", @@ -833,6 +834,18 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@deepgram/sdk": { + "version": "5.0.0", + "resolved": "http://localhost:4873/@deepgram/sdk/-/sdk-5.0.0.tgz", + "integrity": "sha512-x1wMiOgDGqcLEaQpQBQLTtk5mLbXbYgcBEpp7cfJIyEtqdIGgijCZH+a/esiVp+xIcTYYroTxG47RVppZOHbWw==", + "license": "MIT", + "dependencies": { + "ws": "^8.16.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@emnapi/core": { "version": "1.9.0", "resolved": "http://localhost:4873/@emnapi/core/-/core-1.9.0.tgz", diff --git a/package.json b/package.json index 03253d3..a61c058 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "dependencies": { "@ai-sdk/anthropic": "^3.0.58", "@ai-sdk/openai": "^3.0.41", + "@deepgram/sdk": "^5.0.0", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.0.1", diff --git a/src/app.module.ts b/src/app.module.ts index 32588aa..fd8db41 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -10,6 +10,7 @@ import { OzonetelAgentModule } from './ozonetel/ozonetel-agent.module'; import { GraphqlProxyModule } from './graphql-proxy/graphql-proxy.module'; import { HealthModule } from './health/health.module'; import { WorklistModule } from './worklist/worklist.module'; +import { CallAssistModule } from './call-assist/call-assist.module'; @Module({ imports: [ @@ -26,6 +27,7 @@ import { WorklistModule } from './worklist/worklist.module'; GraphqlProxyModule, HealthModule, WorklistModule, + CallAssistModule, ], }) export class AppModule {} diff --git a/src/call-assist/call-assist.gateway.ts b/src/call-assist/call-assist.gateway.ts new file mode 100644 index 0000000..44b5d00 --- /dev/null +++ b/src/call-assist/call-assist.gateway.ts @@ -0,0 +1,136 @@ +import { + WebSocketGateway, + SubscribeMessage, + MessageBody, + ConnectedSocket, + OnGatewayDisconnect, +} from '@nestjs/websockets'; +import { Logger } from '@nestjs/common'; +import { Socket } from 'socket.io'; +import WebSocket from 'ws'; +import { CallAssistService } from './call-assist.service'; + +type SessionState = { + deepgramWs: WebSocket | null; + transcript: string; + context: string; + suggestionTimer: NodeJS.Timeout | null; +}; + +@WebSocketGateway({ + cors: { origin: process.env.CORS_ORIGIN ?? '*', credentials: true }, + namespace: '/call-assist', +}) +export class CallAssistGateway implements OnGatewayDisconnect { + private readonly logger = new Logger(CallAssistGateway.name); + private readonly sessions = new Map(); + private readonly deepgramApiKey: string; + + constructor(private readonly callAssist: CallAssistService) { + this.deepgramApiKey = process.env.DEEPGRAM_API_KEY ?? ''; + } + + @SubscribeMessage('call-assist:start') + async handleStart( + @ConnectedSocket() client: Socket, + @MessageBody() data: { ucid: string; leadId?: string; callerPhone?: string }, + ) { + this.logger.log(`Call assist start: ucid=${data.ucid} lead=${data.leadId ?? 'none'}`); + + const context = await this.callAssist.loadCallContext( + data.leadId ?? null, + data.callerPhone ?? null, + ); + client.emit('call-assist:context', { context: context.substring(0, 200) + '...' }); + + const session: SessionState = { + deepgramWs: null, + transcript: '', + context, + suggestionTimer: null, + }; + + if (this.deepgramApiKey) { + const dgUrl = `wss://api.deepgram.com/v1/listen?model=nova-2&language=en&smart_format=true&interim_results=true&endpointing=300&sample_rate=16000&encoding=linear16&channels=1`; + + const dgWs = new WebSocket(dgUrl, { + headers: { Authorization: `Token ${this.deepgramApiKey}` }, + }); + + dgWs.on('open', () => { + this.logger.log(`Deepgram connected for ${data.ucid}`); + }); + + dgWs.on('message', (raw: WebSocket.Data) => { + try { + const result = JSON.parse(raw.toString()); + const text = result.channel?.alternatives?.[0]?.transcript; + if (!text) return; + + const isFinal = result.is_final; + client.emit('call-assist:transcript', { text, isFinal }); + + if (isFinal) { + session.transcript += `Customer: ${text}\n`; + } + } catch {} + }); + + dgWs.on('error', (err) => { + this.logger.error(`Deepgram error: ${err.message}`); + }); + + dgWs.on('close', () => { + this.logger.log(`Deepgram closed for ${data.ucid}`); + }); + + session.deepgramWs = dgWs; + } else { + this.logger.warn('DEEPGRAM_API_KEY not set — transcription disabled'); + client.emit('call-assist:error', { message: 'Transcription not configured' }); + } + + // AI suggestion every 10 seconds + session.suggestionTimer = setInterval(async () => { + if (!session.transcript.trim()) return; + const suggestion = await this.callAssist.getSuggestion(session.transcript, session.context); + if (suggestion) { + client.emit('call-assist:suggestion', { text: suggestion }); + } + }, 10000); + + this.sessions.set(client.id, session); + } + + @SubscribeMessage('call-assist:audio') + handleAudio( + @ConnectedSocket() client: Socket, + @MessageBody() audioData: ArrayBuffer, + ) { + const session = this.sessions.get(client.id); + if (session?.deepgramWs?.readyState === WebSocket.OPEN) { + session.deepgramWs.send(Buffer.from(audioData)); + } + } + + @SubscribeMessage('call-assist:stop') + handleStop(@ConnectedSocket() client: Socket) { + this.cleanup(client.id); + this.logger.log(`Call assist stopped: ${client.id}`); + } + + handleDisconnect(client: Socket) { + this.cleanup(client.id); + } + + private cleanup(clientId: string) { + const session = this.sessions.get(clientId); + if (session) { + if (session.suggestionTimer) clearInterval(session.suggestionTimer); + if (session.deepgramWs) { + try { session.deepgramWs.close(); } catch {} + } + this.sessions.delete(clientId); + } + } +} diff --git a/src/call-assist/call-assist.module.ts b/src/call-assist/call-assist.module.ts new file mode 100644 index 0000000..cec11c2 --- /dev/null +++ b/src/call-assist/call-assist.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CallAssistGateway } from './call-assist.gateway'; +import { CallAssistService } from './call-assist.service'; +import { PlatformModule } from '../platform/platform.module'; + +@Module({ + imports: [PlatformModule], + providers: [CallAssistGateway, CallAssistService], +}) +export class CallAssistModule {} diff --git a/src/call-assist/call-assist.service.ts b/src/call-assist/call-assist.service.ts new file mode 100644 index 0000000..e20f577 --- /dev/null +++ b/src/call-assist/call-assist.service.ts @@ -0,0 +1,123 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { generateText } from 'ai'; +import { PlatformGraphqlService } from '../platform/platform-graphql.service'; +import { createAiModel } from '../ai/ai-provider'; +import type { LanguageModel } from 'ai'; + +@Injectable() +export class CallAssistService { + private readonly logger = new Logger(CallAssistService.name); + private readonly aiModel: LanguageModel | null; + private readonly platformApiKey: string; + + constructor( + private config: ConfigService, + private platform: PlatformGraphqlService, + ) { + this.aiModel = createAiModel(config); + this.platformApiKey = config.get('platform.apiKey') ?? ''; + } + + async loadCallContext(leadId: string | null, callerPhone: string | null): Promise { + const authHeader = this.platformApiKey ? `Bearer ${this.platformApiKey}` : ''; + if (!authHeader) return 'No platform context available.'; + + try { + const parts: string[] = []; + + if (leadId) { + const leadResult = await this.platform.queryWithAuth( + `{ leads(filter: { id: { eq: "${leadId}" } }) { edges { node { + id name contactName { firstName lastName } + contactPhone { primaryPhoneNumber } + source status interestedService + lastContacted contactAttempts + aiSummary aiSuggestedAction + } } } }`, + undefined, authHeader, + ); + const lead = leadResult.leads.edges[0]?.node; + if (lead) { + const name = lead.contactName + ? `${lead.contactName.firstName} ${lead.contactName.lastName}`.trim() + : lead.name; + parts.push(`CALLER: ${name}`); + parts.push(`Phone: ${lead.contactPhone?.primaryPhoneNumber ?? callerPhone}`); + parts.push(`Source: ${lead.source ?? 'Unknown'}`); + parts.push(`Interested in: ${lead.interestedService ?? 'Not specified'}`); + parts.push(`Contact attempts: ${lead.contactAttempts ?? 0}`); + if (lead.aiSummary) parts.push(`AI Summary: ${lead.aiSummary}`); + } + + const apptResult = await this.platform.queryWithAuth( + `{ appointments(first: 10, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { + id scheduledAt appointmentStatus doctorName department reasonForVisit patientId + } } } }`, + undefined, authHeader, + ); + const appts = apptResult.appointments.edges + .map((e: any) => e.node) + .filter((a: any) => a.patientId === leadId); + if (appts.length > 0) { + parts.push('\nPAST APPOINTMENTS:'); + for (const a of appts) { + const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN') : '?'; + parts.push(`- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.appointmentStatus}`); + } + } + } else if (callerPhone) { + parts.push(`CALLER: Unknown (${callerPhone})`); + parts.push('No lead record found — this may be a new enquiry.'); + } + + const docResult = await this.platform.queryWithAuth( + `{ doctors(first: 20) { edges { node { + fullName { firstName lastName } department specialty clinic { clinicName } + } } } }`, + undefined, authHeader, + ); + const docs = docResult.doctors.edges.map((e: any) => e.node); + if (docs.length > 0) { + parts.push('\nAVAILABLE DOCTORS:'); + for (const d of docs) { + const name = d.fullName ? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim() : 'Unknown'; + parts.push(`- ${name} — ${d.department ?? '?'} — ${d.clinic?.clinicName ?? '?'}`); + } + } + + return parts.join('\n') || 'No context available.'; + } catch (err) { + this.logger.error(`Failed to load call context: ${err}`); + return 'Context loading failed.'; + } + } + + async getSuggestion(transcript: string, context: string): Promise { + if (!this.aiModel || !transcript.trim()) return ''; + + try { + const { text } = await generateText({ + model: this.aiModel, + system: `You are a real-time call assistant for Global Hospital Bangalore. +You listen to the customer's words and provide brief, actionable suggestions for the CC agent. + +${context} + +RULES: +- Keep suggestions under 2 sentences +- Focus on actionable next steps the agent should take NOW +- If customer mentions a doctor or department, suggest available slots +- If customer wants to cancel or reschedule, note relevant appointment details +- If customer sounds upset, suggest empathetic response +- Do NOT repeat what the agent already knows`, + prompt: `Conversation transcript so far:\n${transcript}\n\nProvide a brief suggestion for the agent based on what was just said.`, + maxOutputTokens: 150, + }); + return text; + } catch (err) { + this.logger.error(`AI suggestion failed: ${err}`); + return ''; + } + } +} diff --git a/src/ozonetel/ozonetel-agent.controller.ts b/src/ozonetel/ozonetel-agent.controller.ts index 73308e5..8f95b6c 100644 --- a/src/ozonetel/ozonetel-agent.controller.ts +++ b/src/ozonetel/ozonetel-agent.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Post, Body, Logger, HttpException } from '@nestjs/common'; +import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { OzonetelAgentService } from './ozonetel-agent.service'; @@ -136,6 +136,72 @@ export class OzonetelAgentController { } } + @Post('call-control') + async callControl( + @Body() body: { + action: 'CONFERENCE' | 'HOLD' | 'UNHOLD' | 'MUTE' | 'UNMUTE' | 'KICK_CALL'; + ucid: string; + conferenceNumber?: string; + }, + ) { + if (!body.action || !body.ucid) { + throw new HttpException('action and ucid required', 400); + } + if (body.action === 'CONFERENCE' && !body.conferenceNumber) { + throw new HttpException('conferenceNumber required for CONFERENCE action', 400); + } + + this.logger.log(`Call control: ${body.action} ucid=${body.ucid}`); + + try { + const result = await this.ozonetelAgent.callControl(body); + return result; + } catch (error: any) { + const message = error.response?.data?.message ?? error.message ?? 'Call control failed'; + throw new HttpException(message, error.response?.status ?? 502); + } + } + + @Post('recording') + async recording( + @Body() body: { ucid: string; action: 'pause' | 'unPause' }, + ) { + if (!body.ucid || !body.action) { + throw new HttpException('ucid and action required', 400); + } + + try { + const result = await this.ozonetelAgent.pauseRecording(body); + return result; + } catch (error: any) { + const message = error.response?.data?.message ?? error.message ?? 'Recording control failed'; + throw new HttpException(message, error.response?.status ?? 502); + } + } + + @Get('missed-calls') + async missedCalls() { + const result = await this.ozonetelAgent.getAbandonCalls(); + return result; + } + + @Get('call-history') + async callHistory( + @Query('date') date?: string, + @Query('status') status?: string, + @Query('callType') callType?: string, + ) { + const targetDate = date ?? new Date().toISOString().split('T')[0]; + this.logger.log(`Call history: date=${targetDate} status=${status ?? 'all'} type=${callType ?? 'all'}`); + + const result = await this.ozonetelAgent.fetchCDR({ + date: targetDate, + status, + callType, + }); + return result; + } + private mapToOzonetelDisposition(disposition: string): string { // Campaign only has 'General Enquiry' configured currently const map: Record = { diff --git a/src/ozonetel/ozonetel-agent.service.ts b/src/ozonetel/ozonetel-agent.service.ts index dd595d6..395692a 100644 --- a/src/ozonetel/ozonetel-agent.service.ts +++ b/src/ozonetel/ozonetel-agent.service.ts @@ -193,6 +193,165 @@ export class OzonetelAgentService { } } + async callControl(params: { + action: 'CONFERENCE' | 'HOLD' | 'UNHOLD' | 'MUTE' | 'UNMUTE' | 'KICK_CALL'; + ucid: string; + conferenceNumber?: string; + }): Promise<{ status: string; message: string; ucid?: string }> { + const url = `https://${this.apiDomain}/ca_apis/CallControl_V4`; + const did = process.env.OZONETEL_DID ?? '918041763265'; + const agentPhoneName = process.env.OZONETEL_SIP_ID ?? '523590'; + + this.logger.log(`Call control: action=${params.action} ucid=${params.ucid} conference=${params.conferenceNumber ?? 'none'}`); + + try { + const token = await this.getToken(); + const body: Record = { + userName: this.accountId, + action: params.action, + ucid: params.ucid, + did, + agentPhoneName, + }; + if (params.conferenceNumber) { + body.conferenceNumber = params.conferenceNumber; + } + + const response = await axios.post(url, body, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + this.logger.log(`Call control response: ${JSON.stringify(response.data)}`); + return response.data; + } catch (error: any) { + const responseData = error?.response?.data ? JSON.stringify(error.response.data) : ''; + this.logger.error(`Call control failed: ${error.message} ${responseData}`); + throw error; + } + } + + async pauseRecording(params: { + ucid: string; + action: 'pause' | 'unPause'; + }): Promise<{ status: string; message: string }> { + const url = `https://${this.apiDomain}/CAServices/Call/Record.php`; + + this.logger.log(`Recording ${params.action}: ucid=${params.ucid}`); + + try { + const response = await axios.get(url, { + params: { + userName: this.accountId, + apiKey: this.apiKey, + action: params.action, + ucid: params.ucid, + }, + }); + + this.logger.log(`Recording control response: ${JSON.stringify(response.data)}`); + return response.data; + } catch (error: any) { + const responseData = error?.response?.data ? JSON.stringify(error.response.data) : ''; + this.logger.error(`Recording control failed: ${error.message} ${responseData}`); + throw error; + } + } + + async getAbandonCalls(params?: { + fromTime?: string; + toTime?: string; + campaignName?: string; + }): Promise> { + const url = `https://${this.apiDomain}/ca_apis/abandonCalls`; + + this.logger.log('Fetching abandon calls'); + + try { + const token = await this.getToken(); + const body: Record = { userName: this.accountId }; + if (params?.fromTime) body.fromTime = params.fromTime; + if (params?.toTime) body.toTime = params.toTime; + if (params?.campaignName) body.campaignName = params.campaignName; + + const response = await axios({ + method: 'GET', + url, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: JSON.stringify(body), + }); + + const data = response.data; + this.logger.log(`Abandon calls response: ${JSON.stringify(data).substring(0, 200)}`); + if (data.status === 'success' && Array.isArray(data.message)) { + return data.message; + } + return []; + } catch (error: any) { + this.logger.error(`Abandon calls failed: ${error.message}`); + return []; + } + } + + async fetchCDR(params: { + date: string; // YYYY-MM-DD + campaignName?: string; + status?: string; + callType?: string; + }): Promise>> { + const url = `https://${this.apiDomain}/ca_reports/fetchCDRDetails`; + + this.logger.log(`Fetch CDR: date=${params.date}`); + + try { + const token = await this.getToken(); + const body: Record = { + userName: this.accountId, + fromDate: `${params.date} 00:00:00`, + toDate: `${params.date} 23:59:59`, + }; + if (params.campaignName) body.campaignName = params.campaignName; + if (params.status) body.status = params.status; + if (params.callType) body.callType = params.callType; + + const response = await axios({ + method: 'GET', + url, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + data: JSON.stringify(body), + }); + + const data = response.data; + if (data.status === 'success' && Array.isArray(data.details)) { + return data.details; + } + return []; + } catch (error: any) { + const responseData = error?.response?.data ? JSON.stringify(error.response.data) : ''; + this.logger.error(`Fetch CDR failed: ${error.message} ${responseData}`); + return []; + } + } + async logoutAgent(params: { agentId: string; password: string;