feat: add Ozonetel Set Disposition API for proper ACW release

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 18:35:59 +05:30
parent 72e6373acf
commit 8c6cd2c156
2 changed files with 170 additions and 47 deletions

View File

@@ -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') @Post('dial')
async dial( async dial(
@Body() body: { phoneNumber: string; leadId?: string }, @Body() body: { phoneNumber: string; campaignName?: string; leadId?: string },
) { ) {
if (!body.phoneNumber) { if (!body.phoneNumber) {
throw new HttpException('phoneNumber required', 400); 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 { try {
const result = await this.ozonetelAgent.dialCustomer({ const result = await this.ozonetelAgent.manualDial({
agentId: this.defaultAgentId,
campaignName,
customerNumber: body.phoneNumber, customerNumber: body.phoneNumber,
}); });
return result; return result;
} catch (error: any) { } catch (error: any) {
const message = error.response?.data?.message ?? error.message ?? 'Dial failed'; 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<string, string> = {
'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';
}
} }

View File

@@ -8,6 +8,8 @@ export class OzonetelAgentService {
private readonly apiDomain: string; private readonly apiDomain: string;
private readonly apiKey: string; private readonly apiKey: string;
private readonly accountId: string; private readonly accountId: string;
private cachedToken: string | null = null;
private tokenExpiry: number = 0;
constructor(private config: ConfigService) { constructor(private config: ConfigService) {
this.apiDomain = config.get<string>('exotel.subdomain') ?? 'in1-ccaas-api.ozonetel.com'; this.apiDomain = config.get<string>('exotel.subdomain') ?? 'in1-ccaas-api.ozonetel.com';
@@ -15,6 +17,29 @@ export class OzonetelAgentService {
this.accountId = config.get<string>('exotel.accountSid') ?? ''; this.accountId = config.get<string>('exotel.accountSid') ?? '';
} }
private async getToken(): Promise<string> {
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: { async loginAgent(params: {
agentId: string; agentId: string;
password: string; password: string;
@@ -63,61 +88,107 @@ export class OzonetelAgentService {
} }
} }
async dialCustomer(params: { async manualDial(params: {
agentId: string;
campaignName: string;
customerNumber: string; customerNumber: string;
callbackUrl?: string; }): Promise<{ status: string; ucid?: string; message?: string }> {
ivrUrl?: string; const url = `https://${this.apiDomain}/ca_apis/AgentManualDial`;
}): 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()}`;
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 { try {
// Call 1: Call the customer → put in conference room const token = await this.getToken();
const customerParams = new URLSearchParams({ const response = await axios.post(url, {
phone_no: params.customerNumber, userName: this.accountId,
api_key: this.apiKey, agentID: params.agentId,
outbound_version: '2', campaignName: params.campaignName,
caller_id: callerId, customerNumber: params.customerNumber,
callback_url: params.callbackUrl ?? `${callbackBase}/webhooks/kookoo/callback`, UCID: 'true',
extra_data: `<response><playtext>Please wait while we connect you to an agent</playtext><conference>${roomId}</conference></response>`, }, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
}); });
// Call 2: Call the DID (which routes to agent via CloudAgent) → put in same conference room this.logger.log(`Manual dial response: ${JSON.stringify(response.data)}`);
const agentParams = new URLSearchParams({ return response.data;
phone_no: callerId, } catch (error: any) {
api_key: this.apiKey, const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
outbound_version: '2', this.logger.error(`Manual dial failed: ${error.message} ${responseData}`);
caller_id: params.customerNumber, throw error;
extra_data: `<response><conference>${roomId}</conference></response>`, }
}); }
// Fire both calls async changeAgentState(params: {
const [customerRes, agentRes] = await Promise.all([ agentId: string;
axios.get(`https://in1-cpaas.ozonetel.com/outbound/outbound.php?${customerParams.toString()}`), state: 'Ready' | 'Pause';
axios.get(`https://in1-cpaas.ozonetel.com/outbound/outbound.php?${agentParams.toString()}`), 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); this.logger.log(`Changing agent ${params.agentId} state to ${params.state}`);
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}`);
// Parse XML response: <response><status>queued</status><message>SID</message></response> try {
const statusMatch = responseText.match(/<status>(.*?)<\/status>/); const body: Record<string, string> = {
const messageMatch = responseText.match(/<message>(.*?)<\/message>/); userName: this.accountId,
const status = statusMatch?.[1] ?? 'unknown'; agentId: params.agentId,
const message = messageMatch?.[1] ?? responseText; state: params.state,
};
if (status === 'error') { if (params.pauseReason) {
throw new Error(message); 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) { } 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; throw error;
} }
} }