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')
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<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 apiKey: string;
private readonly accountId: string;
private cachedToken: string | null = null;
private tokenExpiry: number = 0;
constructor(private config: ConfigService) {
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') ?? '';
}
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: {
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: `<response><playtext>Please wait while we connect you to an agent</playtext><conference>${roomId}</conference></response>`,
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: `<response><conference>${roomId}</conference></response>`,
});
// 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()}`),
]);
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}`);
// 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);
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;
}
}
return { status, message };
async changeAgentState(params: {
agentId: string;
state: 'Ready' | 'Pause';
pauseReason?: string;
}): Promise<{ status: string; message: string }> {
const url = `https://${this.apiDomain}/ca_apis/changeAgentState`;
this.logger.log(`Changing agent ${params.agentId} state to ${params.state}`);
try {
const body: Record<string, string> = {
userName: this.accountId,
agentId: params.agentId,
state: params.state,
};
if (params.pauseReason) {
body.pauseReason = params.pauseReason;
}
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;
}
}