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) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 10:36:35 +05:30
parent 58225b7943
commit bbf77ed0e9
8 changed files with 511 additions and 1 deletions

View File

@@ -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<string, SessionState>();
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);
}
}
}

View File

@@ -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 {}

View File

@@ -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<string>('platform.apiKey') ?? '';
}
async loadCallContext(leadId: string | null, callerPhone: string | null): Promise<string> {
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<any>(
`{ 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<any>(
`{ 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<any>(
`{ 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<string> {
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 '';
}
}
}