mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
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:
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user