From 6812006b533d7369b6d07d101b2116e8c5868ea3 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Thu, 19 Mar 2026 18:03:44 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20switch=20outbound=20dial=20to=20Kookoo?= =?UTF-8?q?=20API=20=E2=80=94=20outbound=20calls=20now=20work?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace CloudAgent V3 tbManualDial with Kookoo outbound.php - Simple HTTP GET with api_key — no auth issues - Kookoo callback endpoint: POST /webhooks/kookoo/callback - Creates Call record in platform - Matches caller to Lead by phone - Remove agent login requirement before dial - Tested: call queued successfully, phone rang Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ozonetel/ozonetel-agent.controller.ts | 9 --- src/ozonetel/ozonetel-agent.service.ts | 66 +++++++++------- src/worklist/kookoo-callback.controller.ts | 90 ++++++++++++++++++++++ src/worklist/worklist.module.ts | 3 +- 4 files changed, 131 insertions(+), 37 deletions(-) create mode 100644 src/worklist/kookoo-callback.controller.ts diff --git a/src/ozonetel/ozonetel-agent.controller.ts b/src/ozonetel/ozonetel-agent.controller.ts index 5490545..495bc25 100644 --- a/src/ozonetel/ozonetel-agent.controller.ts +++ b/src/ozonetel/ozonetel-agent.controller.ts @@ -64,16 +64,7 @@ export class OzonetelAgentController { this.logger.log(`Dial request: ${body.phoneNumber} (lead: ${body.leadId ?? 'none'})`); try { - // Ensure agent is logged in before dialing - await this.ozonetelAgent.loginAgent({ - agentId: this.defaultAgentId, - password: this.defaultAgentPassword, - phoneNumber: this.defaultSipId, - mode: 'blended', - }); - const result = await this.ozonetelAgent.dialCustomer({ - agentId: this.defaultAgentId, customerNumber: body.phoneNumber, }); return result; diff --git a/src/ozonetel/ozonetel-agent.service.ts b/src/ozonetel/ozonetel-agent.service.ts index 1459b47..5bfd5c3 100644 --- a/src/ozonetel/ozonetel-agent.service.ts +++ b/src/ozonetel/ozonetel-agent.service.ts @@ -64,40 +64,52 @@ export class OzonetelAgentService { } async dialCustomer(params: { - agentId: string; customerNumber: string; - campaignName?: string; - }): Promise<{ type: string; agentId: string; user: string }> { - const url = `https://${this.apiDomain}/CAServicesV3/mdlConnection.php`; - const basicAuth = Buffer.from(`${this.accountId}:${this.apiKey}`).toString('base64'); + callbackUrl?: string; + ivrUrl?: string; + }): Promise<{ status: string; message: string }> { + const callerId = process.env.OZONETEL_DID ?? '918041763265'; + const callbackBase = process.env.KOOKOO_CALLBACK_URL ?? 'https://engage-api.srv1477139.hstgr.cloud'; - this.logger.log(`Dialing ${params.customerNumber} for agent ${params.agentId}`); + this.logger.log(`Kookoo outbound: dialing ${params.customerNumber}`); try { - const response = await axios.post( - url, - { - type: 'tbManualDial', - ns: 'ozonetel.cloudagent', - customer: this.accountId, - agentId: params.agentId, - custNumber: params.customerNumber, - campaignName: params.campaignName ?? 'Inbound_918041763265', - timestamp: Date.now(), - }, - { - headers: { - 'Apikey': this.apiKey, - 'Authorization': `Basic ${basicAuth}`, - 'Content-Type': 'application/json', - }, - }, + const queryParams = new URLSearchParams({ + phone_no: params.customerNumber, + api_key: this.apiKey, + outbound_version: '2', + caller_id: callerId, + callback_url: params.callbackUrl ?? `${callbackBase}/webhooks/kookoo/callback`, + }); + + // If IVR URL provided, Kookoo will hit it when call connects + // Otherwise use extra_data for simple TTS + hangup + if (params.ivrUrl) { + queryParams.set('url', params.ivrUrl); + } else { + queryParams.set('extra_data', 'Connecting you now'); + } + + const response = await axios.get( + `https://in1-cpaas.ozonetel.com/outbound/outbound.php?${queryParams.toString()}`, ); - this.logger.log(`Dial response: ${JSON.stringify(response.data)}`); - return response.data; + const responseText = typeof response.data === 'string' ? response.data : String(response.data); + this.logger.log(`Kookoo dial response: ${responseText}`); + + // Parse XML response: queuedSID + const statusMatch = responseText.match(/(.*?)<\/status>/); + const messageMatch = responseText.match(/(.*?)<\/message>/); + const status = statusMatch?.[1] ?? 'unknown'; + const message = messageMatch?.[1] ?? responseText; + + if (status === 'error') { + throw new Error(message); + } + + return { status, message }; } catch (error: any) { - this.logger.error(`Dial failed: ${error.response?.data?.message ?? error.message}`); + this.logger.error(`Kookoo dial failed: ${error.message}`); throw error; } } diff --git a/src/worklist/kookoo-callback.controller.ts b/src/worklist/kookoo-callback.controller.ts new file mode 100644 index 0000000..932e0c2 --- /dev/null +++ b/src/worklist/kookoo-callback.controller.ts @@ -0,0 +1,90 @@ +import { Controller, Post, Body, Query, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PlatformGraphqlService } from '../platform/platform-graphql.service'; + +@Controller('webhooks/kookoo') +export class KookooCallbackController { + private readonly logger = new Logger(KookooCallbackController.name); + private readonly apiKey: string; + + constructor( + private readonly platform: PlatformGraphqlService, + private readonly config: ConfigService, + ) { + this.apiKey = config.get('platform.apiKey') ?? ''; + } + + @Post('callback') + async handleCallback(@Body() body: Record, @Query() query: Record) { + // Kookoo sends params as both query and body + const params = { ...query, ...body }; + this.logger.log(`Kookoo callback: sid=${params.sid} status=${params.status} phone=${params.phone_no} duration=${params.duration}`); + + const phoneNumber = (params.phone_no ?? '').replace(/^\+?91/, ''); + const status = params.status ?? 'unknown'; + const duration = parseInt(params.duration ?? '0', 10); + const callerId = params.caller_id ?? ''; + const startTime = params.start_time ?? null; + const endTime = params.end_time ?? null; + const sid = params.sid ?? null; + + if (!phoneNumber) { + return { received: true, processed: false }; + } + + const callStatus = status === 'answered' ? 'COMPLETED' : 'MISSED'; + const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : ''; + + if (!authHeader) { + this.logger.warn('No PLATFORM_API_KEY — cannot write call records'); + return { received: true, processed: false }; + } + + try { + // Create call record + const callResult = await this.platform.queryWithAuth( + `mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`, + { + data: { + name: `Outbound — ${phoneNumber}`, + direction: 'OUTBOUND', + callStatus, + startedAt: startTime ? new Date(startTime).toISOString() : new Date().toISOString(), + endedAt: endTime ? new Date(endTime).toISOString() : null, + durationSec: duration, + }, + }, + authHeader, + ); + + this.logger.log(`Created outbound call record: ${callResult.createCall.id} (${callStatus}, ${duration}s)`); + + // Try to match to a lead + const leadResult = await this.platform.queryWithAuth( + `{ leads(first: 50) { edges { node { id name contactPhone { primaryPhoneNumber } } } } }`, + undefined, + authHeader, + ); + const leads = leadResult.leads.edges.map((e: any) => e.node); + const cleanPhone = phoneNumber.replace(/\D/g, ''); + const matchedLead = leads.find((l: any) => { + const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, ''); + return lp.endsWith(cleanPhone) || cleanPhone.endsWith(lp); + }); + + if (matchedLead) { + await this.platform.queryWithAuth( + `mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`, + { id: callResult.createCall.id, data: { leadId: matchedLead.id } }, + authHeader, + ); + this.logger.log(`Linked call to lead ${matchedLead.id} (${matchedLead.name})`); + } + + return { received: true, processed: true, callId: callResult.createCall.id }; + } catch (err) { + this.logger.error(`Kookoo callback processing failed: ${err}`); + return { received: true, processed: false }; + } + } +} diff --git a/src/worklist/worklist.module.ts b/src/worklist/worklist.module.ts index 10eb99a..5b7731c 100644 --- a/src/worklist/worklist.module.ts +++ b/src/worklist/worklist.module.ts @@ -3,10 +3,11 @@ import { PlatformModule } from '../platform/platform.module'; import { WorklistController } from './worklist.controller'; import { WorklistService } from './worklist.service'; import { MissedCallWebhookController } from './missed-call-webhook.controller'; +import { KookooCallbackController } from './kookoo-callback.controller'; @Module({ imports: [PlatformModule], - controllers: [WorklistController, MissedCallWebhookController], + controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController], providers: [WorklistService], }) export class WorklistModule {}