From 8c6cd2c156e14c682920c7f1559eea1fbfa62f30 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Fri, 20 Mar 2026 18:35:59 +0530 Subject: [PATCH] feat: add Ozonetel Set Disposition API for proper ACW release Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ozonetel/ozonetel-agent.controller.ts | 60 ++++++++- src/ozonetel/ozonetel-agent.service.ts | 157 ++++++++++++++++------ 2 files changed, 170 insertions(+), 47 deletions(-) diff --git a/src/ozonetel/ozonetel-agent.controller.ts b/src/ozonetel/ozonetel-agent.controller.ts index 495bc25..762e57d 100644 --- a/src/ozonetel/ozonetel-agent.controller.ts +++ b/src/ozonetel/ozonetel-agent.controller.ts @@ -53,24 +53,76 @@ export class OzonetelAgentController { } } + @Post('dispose') + async dispose( + @Body() body: { + ucid: string; + disposition: string; + callerPhone?: string; + direction?: string; + durationSec?: number; + leadId?: string; + notes?: string; + }, + ) { + if (!body.ucid || !body.disposition) { + throw new HttpException('ucid and disposition required', 400); + } + + this.logger.log(`Dispose: ucid=${body.ucid} disposition=${body.disposition}`); + + const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition); + + try { + const result = await this.ozonetelAgent.setDisposition({ + agentId: this.defaultAgentId, + ucid: body.ucid, + disposition: ozonetelDisposition, + }); + return result; + } catch (error: any) { + const message = error.response?.data?.message ?? error.message ?? 'Disposition failed'; + this.logger.error(`Dispose failed: ${message}`); + // Don't throw — disposition failure shouldn't block the UI + return { status: 'error', message }; + } + } + @Post('dial') async dial( - @Body() body: { phoneNumber: string; leadId?: string }, + @Body() body: { phoneNumber: string; campaignName?: string; leadId?: string }, ) { if (!body.phoneNumber) { throw new HttpException('phoneNumber required', 400); } - this.logger.log(`Dial request: ${body.phoneNumber} (lead: ${body.leadId ?? 'none'})`); + const campaignName = body.campaignName ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265'; + + this.logger.log(`Manual dial: ${body.phoneNumber} campaign=${campaignName} (lead: ${body.leadId ?? 'none'})`); try { - const result = await this.ozonetelAgent.dialCustomer({ + const result = await this.ozonetelAgent.manualDial({ + agentId: this.defaultAgentId, + campaignName, customerNumber: body.phoneNumber, }); return result; } catch (error: any) { const message = error.response?.data?.message ?? error.message ?? 'Dial failed'; - throw new HttpException(message, 502); + throw new HttpException(message, error.response?.status ?? 502); } } + + private mapToOzonetelDisposition(disposition: string): string { + // Campaign only has 'General Enquiry' configured currently + const map: Record = { + 'APPOINTMENT_BOOKED': 'General Enquiry', + 'FOLLOW_UP_SCHEDULED': 'General Enquiry', + 'INFO_PROVIDED': 'General Enquiry', + 'NO_ANSWER': 'General Enquiry', + 'WRONG_NUMBER': 'General Enquiry', + 'CALLBACK_REQUESTED': 'General Enquiry', + }; + return map[disposition] ?? 'General Enquiry'; + } } diff --git a/src/ozonetel/ozonetel-agent.service.ts b/src/ozonetel/ozonetel-agent.service.ts index 2ea43a8..dd595d6 100644 --- a/src/ozonetel/ozonetel-agent.service.ts +++ b/src/ozonetel/ozonetel-agent.service.ts @@ -8,6 +8,8 @@ export class OzonetelAgentService { private readonly apiDomain: string; private readonly apiKey: string; private readonly accountId: string; + private cachedToken: string | null = null; + private tokenExpiry: number = 0; constructor(private config: ConfigService) { this.apiDomain = config.get('exotel.subdomain') ?? 'in1-ccaas-api.ozonetel.com'; @@ -15,6 +17,29 @@ export class OzonetelAgentService { this.accountId = config.get('exotel.accountSid') ?? ''; } + private async getToken(): Promise { + if (this.cachedToken && Date.now() < this.tokenExpiry) { + return this.cachedToken; + } + + const url = `https://${this.apiDomain}/ca_apis/CAToken/generateToken`; + this.logger.log('Generating CloudAgent API token'); + + const response = await axios.post(url, { userName: this.accountId }, { + headers: { apiKey: this.apiKey, 'Content-Type': 'application/json' }, + }); + + const data = response.data; + if (data.token) { + this.cachedToken = data.token; + this.tokenExpiry = Date.now() + 55 * 60 * 1000; + this.logger.log('CloudAgent token generated successfully'); + return data.token; + } + + throw new Error(data.message ?? 'Token generation failed'); + } + async loginAgent(params: { agentId: string; password: string; @@ -63,61 +88,107 @@ export class OzonetelAgentService { } } - async dialCustomer(params: { + async manualDial(params: { + agentId: string; + campaignName: string; customerNumber: string; - 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'; - const roomId = `room-${Date.now()}`; + }): Promise<{ status: string; ucid?: string; message?: string }> { + const url = `https://${this.apiDomain}/ca_apis/AgentManualDial`; - this.logger.log(`Kookoo outbound: dialing ${params.customerNumber}, conference room: ${roomId}`); + this.logger.log(`Manual dial: agent=${params.agentId} campaign=${params.campaignName} number=${params.customerNumber}`); try { - // Call 1: Call the customer → put in conference room - const customerParams = new URLSearchParams({ - phone_no: params.customerNumber, - api_key: this.apiKey, - outbound_version: '2', - caller_id: callerId, - callback_url: params.callbackUrl ?? `${callbackBase}/webhooks/kookoo/callback`, - extra_data: `Please wait while we connect you to an agent${roomId}`, + const token = await this.getToken(); + const response = await axios.post(url, { + userName: this.accountId, + agentID: params.agentId, + campaignName: params.campaignName, + customerNumber: params.customerNumber, + UCID: 'true', + }, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, }); - // Call 2: Call the DID (which routes to agent via CloudAgent) → put in same conference room - const agentParams = new URLSearchParams({ - phone_no: callerId, - api_key: this.apiKey, - outbound_version: '2', - caller_id: params.customerNumber, - extra_data: `${roomId}`, - }); + this.logger.log(`Manual dial response: ${JSON.stringify(response.data)}`); + return response.data; + } catch (error: any) { + const responseData = error?.response?.data ? JSON.stringify(error.response.data) : ''; + this.logger.error(`Manual dial failed: ${error.message} ${responseData}`); + throw error; + } + } - // Fire both calls - const [customerRes, agentRes] = await Promise.all([ - axios.get(`https://in1-cpaas.ozonetel.com/outbound/outbound.php?${customerParams.toString()}`), - axios.get(`https://in1-cpaas.ozonetel.com/outbound/outbound.php?${agentParams.toString()}`), - ]); + async changeAgentState(params: { + agentId: string; + state: 'Ready' | 'Pause'; + pauseReason?: string; + }): Promise<{ status: string; message: string }> { + const url = `https://${this.apiDomain}/ca_apis/changeAgentState`; - const responseText = typeof customerRes.data === 'string' ? customerRes.data : String(customerRes.data); - const agentResponseText = typeof agentRes.data === 'string' ? agentRes.data : String(agentRes.data); - this.logger.log(`Kookoo customer dial: ${responseText}`); - this.logger.log(`Kookoo agent dial: ${agentResponseText}`); + this.logger.log(`Changing agent ${params.agentId} state to ${params.state}`); - // 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); + try { + const body: Record = { + userName: this.accountId, + agentId: params.agentId, + state: params.state, + }; + if (params.pauseReason) { + body.pauseReason = params.pauseReason; } - return { status, message }; + const token = await this.getToken(); + const response = await axios.post(url, body, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + this.logger.log(`Change agent state response: ${JSON.stringify(response.data)}`); + return response.data; } catch (error: any) { - this.logger.error(`Kookoo dial failed: ${error.message}`); + const responseData = error?.response?.data ? JSON.stringify(error.response.data) : ''; + this.logger.error(`Change agent state failed: ${error.message} ${responseData}`); + throw error; + } + } + + async setDisposition(params: { + agentId: string; + ucid: string; + disposition: string; + }): Promise<{ status: string; message?: string; details?: string }> { + const url = `https://${this.apiDomain}/ca_apis/DispositionAPIV2`; + const did = process.env.OZONETEL_DID ?? '918041763265'; + + this.logger.log(`Set disposition: agent=${params.agentId} ucid=${params.ucid} disposition=${params.disposition}`); + + try { + const token = await this.getToken(); + const response = await axios.post(url, { + userName: this.accountId, + agentID: params.agentId, + did, + ucid: params.ucid, + action: 'Set', + disposition: params.disposition, + autoRelease: 'true', + }, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + this.logger.log(`Set disposition response: ${JSON.stringify(response.data)}`); + return response.data; + } catch (error: any) { + const responseData = error?.response?.data ? JSON.stringify(error.response.data) : ''; + this.logger.error(`Set disposition failed: ${error.message} ${responseData}`); throw error; } }