mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
feat: switch outbound dial to Kookoo API — outbound calls now work
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -64,16 +64,7 @@ export class OzonetelAgentController {
|
|||||||
this.logger.log(`Dial request: ${body.phoneNumber} (lead: ${body.leadId ?? 'none'})`);
|
this.logger.log(`Dial request: ${body.phoneNumber} (lead: ${body.leadId ?? 'none'})`);
|
||||||
|
|
||||||
try {
|
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({
|
const result = await this.ozonetelAgent.dialCustomer({
|
||||||
agentId: this.defaultAgentId,
|
|
||||||
customerNumber: body.phoneNumber,
|
customerNumber: body.phoneNumber,
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -64,40 +64,52 @@ export class OzonetelAgentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async dialCustomer(params: {
|
async dialCustomer(params: {
|
||||||
agentId: string;
|
|
||||||
customerNumber: string;
|
customerNumber: string;
|
||||||
campaignName?: string;
|
callbackUrl?: string;
|
||||||
}): Promise<{ type: string; agentId: string; user: string }> {
|
ivrUrl?: string;
|
||||||
const url = `https://${this.apiDomain}/CAServicesV3/mdlConnection.php`;
|
}): Promise<{ status: string; message: string }> {
|
||||||
const basicAuth = Buffer.from(`${this.accountId}:${this.apiKey}`).toString('base64');
|
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 {
|
try {
|
||||||
const response = await axios.post(
|
const queryParams = new URLSearchParams({
|
||||||
url,
|
phone_no: params.customerNumber,
|
||||||
{
|
api_key: this.apiKey,
|
||||||
type: 'tbManualDial',
|
outbound_version: '2',
|
||||||
ns: 'ozonetel.cloudagent',
|
caller_id: callerId,
|
||||||
customer: this.accountId,
|
callback_url: params.callbackUrl ?? `${callbackBase}/webhooks/kookoo/callback`,
|
||||||
agentId: params.agentId,
|
});
|
||||||
custNumber: params.customerNumber,
|
|
||||||
campaignName: params.campaignName ?? 'Inbound_918041763265',
|
// If IVR URL provided, Kookoo will hit it when call connects
|
||||||
timestamp: Date.now(),
|
// Otherwise use extra_data for simple TTS + hangup
|
||||||
},
|
if (params.ivrUrl) {
|
||||||
{
|
queryParams.set('url', params.ivrUrl);
|
||||||
headers: {
|
} else {
|
||||||
'Apikey': this.apiKey,
|
queryParams.set('extra_data', '<response><playtext>Connecting you now</playtext><hangup/></response>');
|
||||||
'Authorization': `Basic ${basicAuth}`,
|
}
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
const response = await axios.get(
|
||||||
},
|
`https://in1-cpaas.ozonetel.com/outbound/outbound.php?${queryParams.toString()}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Dial response: ${JSON.stringify(response.data)}`);
|
const responseText = typeof response.data === 'string' ? response.data : String(response.data);
|
||||||
return response.data;
|
this.logger.log(`Kookoo dial response: ${responseText}`);
|
||||||
|
|
||||||
|
// Parse XML response: <response><status>queued</status><message>SID</message></response>
|
||||||
|
const statusMatch = responseText.match(/<status>(.*?)<\/status>/);
|
||||||
|
const messageMatch = responseText.match(/<message>(.*?)<\/message>/);
|
||||||
|
const status = statusMatch?.[1] ?? 'unknown';
|
||||||
|
const message = messageMatch?.[1] ?? responseText;
|
||||||
|
|
||||||
|
if (status === 'error') {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status, message };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Dial failed: ${error.response?.data?.message ?? error.message}`);
|
this.logger.error(`Kookoo dial failed: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
90
src/worklist/kookoo-callback.controller.ts
Normal file
90
src/worklist/kookoo-callback.controller.ts
Normal file
@@ -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<string>('platform.apiKey') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('callback')
|
||||||
|
async handleCallback(@Body() body: Record<string, any>, @Query() query: Record<string, any>) {
|
||||||
|
// 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<any>(
|
||||||
|
`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<any>(
|
||||||
|
`{ 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<any>(
|
||||||
|
`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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,11 @@ import { PlatformModule } from '../platform/platform.module';
|
|||||||
import { WorklistController } from './worklist.controller';
|
import { WorklistController } from './worklist.controller';
|
||||||
import { WorklistService } from './worklist.service';
|
import { WorklistService } from './worklist.service';
|
||||||
import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
||||||
|
import { KookooCallbackController } from './kookoo-callback.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule],
|
imports: [PlatformModule],
|
||||||
controllers: [WorklistController, MissedCallWebhookController],
|
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
|
||||||
providers: [WorklistService],
|
providers: [WorklistService],
|
||||||
})
|
})
|
||||||
export class WorklistModule {}
|
export class WorklistModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user