lint and format

This commit is contained in:
Kartik Datrika
2026-03-23 15:46:32 +05:30
parent 30a4cda178
commit a1157ab4c1
48 changed files with 10980 additions and 2810 deletions

View File

@@ -3,49 +3,53 @@ import { ConfigService } from '@nestjs/config';
@Controller('kookoo')
export class KookooIvrController {
private readonly logger = new Logger(KookooIvrController.name);
private readonly sipId: string;
private readonly callerId: string;
private readonly logger = new Logger(KookooIvrController.name);
private readonly sipId: string;
private readonly callerId: string;
constructor(private config: ConfigService) {
this.sipId = process.env.OZONETEL_SIP_ID ?? '523590';
this.callerId = process.env.OZONETEL_DID ?? '918041763265';
}
constructor(private config: ConfigService) {
this.sipId = process.env.OZONETEL_SIP_ID ?? '523590';
this.callerId = process.env.OZONETEL_DID ?? '918041763265';
}
@Get('ivr')
@Header('Content-Type', 'application/xml')
handleIvr(@Query() query: Record<string, any>): string {
const event = query.event ?? '';
const sid = query.sid ?? '';
const cid = query.cid ?? '';
const status = query.status ?? '';
@Get('ivr')
@Header('Content-Type', 'application/xml')
handleIvr(@Query() query: Record<string, any>): string {
const event = query.event ?? '';
const sid = query.sid ?? '';
const cid = query.cid ?? '';
const status = query.status ?? '';
this.logger.log(`Kookoo IVR: event=${event} sid=${sid} cid=${cid} status=${status}`);
this.logger.log(
`Kookoo IVR: event=${event} sid=${sid} cid=${cid} status=${status}`,
);
// New outbound call — customer answered, put them in a conference room
// The room ID is based on the call SID so we can join from the browser
if (event === 'NewCall') {
this.logger.log(`Customer ${cid} answered — dialing DID ${this.callerId} to route to agent`);
return `<?xml version="1.0" encoding="UTF-8"?>
// New outbound call — customer answered, put them in a conference room
// The room ID is based on the call SID so we can join from the browser
if (event === 'NewCall') {
this.logger.log(
`Customer ${cid} answered — dialing DID ${this.callerId} to route to agent`,
);
return `<?xml version="1.0" encoding="UTF-8"?>
<response>
<dial record="true" timeout="30" moh="ring">${this.callerId}</dial>
</response>`;
}
}
// Conference event — user left with #
if (event === 'conference' || event === 'Conference') {
this.logger.log(`Conference event: status=${status}`);
return `<?xml version="1.0" encoding="UTF-8"?>
<response>
<hangup/>
</response>`;
}
// Dial or Disconnect
this.logger.log(`Call ended: event=${event}`);
return `<?xml version="1.0" encoding="UTF-8"?>
// Conference event — user left with #
if (event === 'conference' || event === 'Conference') {
this.logger.log(`Conference event: status=${status}`);
return `<?xml version="1.0" encoding="UTF-8"?>
<response>
<hangup/>
</response>`;
}
// Dial or Disconnect
this.logger.log(`Call ended: event=${event}`);
return `<?xml version="1.0" encoding="UTF-8"?>
<response>
<hangup/>
</response>`;
}
}

View File

@@ -1,4 +1,12 @@
import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common';
import {
Controller,
Post,
Get,
Body,
Query,
Logger,
HttpException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OzonetelAgentService } from './ozonetel-agent.service';
import { MissedQueueService } from '../worklist/missed-queue.service';
@@ -6,328 +14,385 @@ import { PlatformGraphqlService } from '../platform/platform-graphql.service';
@Controller('api/ozonetel')
export class OzonetelAgentController {
private readonly logger = new Logger(OzonetelAgentController.name);
private readonly defaultAgentId: string;
private readonly defaultAgentPassword: string;
private readonly logger = new Logger(OzonetelAgentController.name);
private readonly defaultAgentId: string;
private readonly defaultAgentPassword: string;
private readonly defaultSipId: string;
private readonly defaultSipId: string;
constructor(
private readonly ozonetelAgent: OzonetelAgentService,
private readonly config: ConfigService,
private readonly missedQueue: MissedQueueService,
private readonly platform: PlatformGraphqlService,
) {
this.defaultAgentId = config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
this.defaultAgentPassword = config.get<string>('OZONETEL_AGENT_PASSWORD') ?? '';
this.defaultSipId = config.get<string>('OZONETEL_SIP_ID') ?? '521814';
constructor(
private readonly ozonetelAgent: OzonetelAgentService,
private readonly config: ConfigService,
private readonly missedQueue: MissedQueueService,
private readonly platform: PlatformGraphqlService,
) {
this.defaultAgentId = config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
this.defaultAgentPassword =
config.get<string>('OZONETEL_AGENT_PASSWORD') ?? '';
this.defaultSipId = config.get<string>('OZONETEL_SIP_ID') ?? '521814';
}
@Post('agent-login')
async agentLogin(
@Body()
body: {
agentId: string;
password: string;
phoneNumber: string;
mode?: string;
},
) {
this.logger.log(`Agent login request for ${body.agentId}`);
try {
const result = await this.ozonetelAgent.loginAgent(body);
return result;
} catch (error: any) {
throw new HttpException(
error.response?.data?.message ?? 'Agent login failed',
error.response?.status ?? 500,
);
}
}
@Post('agent-logout')
async agentLogout(@Body() body: { agentId: string; password: string }) {
this.logger.log(`Agent logout request for ${body.agentId}`);
try {
const result = await this.ozonetelAgent.logoutAgent(body);
return result;
} catch (error: any) {
throw new HttpException(
error.response?.data?.message ?? 'Agent logout failed',
error.response?.status ?? 500,
);
}
}
@Post('agent-state')
async agentState(
@Body() body: { state: 'Ready' | 'Pause'; pauseReason?: string },
) {
if (!body.state) {
throw new HttpException('state required', 400);
}
@Post('agent-login')
async agentLogin(
@Body() body: { agentId: string; password: string; phoneNumber: string; mode?: string },
) {
this.logger.log(`Agent login request for ${body.agentId}`);
this.logger.log(
`Agent state change: ${this.defaultAgentId}${body.state} (${body.pauseReason ?? ''})`,
);
try {
const result = await this.ozonetelAgent.loginAgent(body);
return result;
} catch (error: any) {
throw new HttpException(
error.response?.data?.message ?? 'Agent login failed',
error.response?.status ?? 500,
);
}
try {
const result = await this.ozonetelAgent.changeAgentState({
agentId: this.defaultAgentId,
state: body.state,
pauseReason: body.pauseReason,
});
return result;
} catch (error: any) {
const message =
error.response?.data?.message ?? error.message ?? 'State change failed';
return { status: 'error', message };
}
@Post('agent-logout')
async agentLogout(
@Body() body: { agentId: string; password: string },
) {
this.logger.log(`Agent logout request for ${body.agentId}`);
try {
const result = await this.ozonetelAgent.logoutAgent(body);
return result;
} catch (error: any) {
throw new HttpException(
error.response?.data?.message ?? 'Agent logout failed',
error.response?.status ?? 500,
);
// Auto-assign missed call when agent goes Ready
if (body.state === 'Ready') {
try {
const assigned = await this.missedQueue.assignNext(this.defaultAgentId);
if (assigned) {
return {
status: 'ok',
message: `State changed to Ready. Assigned missed call ${assigned.id}`,
assignedCall: assigned,
};
}
} catch (err) {
this.logger.warn(`Auto-assignment on Ready failed: ${err}`);
}
}
}
@Post('agent-ready')
async agentReady() {
this.logger.log(
`Force ready: logging out and back in agent ${this.defaultAgentId}`,
);
try {
await this.ozonetelAgent.logoutAgent({
agentId: this.defaultAgentId,
password: this.defaultAgentPassword,
});
const result = await this.ozonetelAgent.loginAgent({
agentId: this.defaultAgentId,
password: this.defaultAgentPassword,
phoneNumber: this.defaultSipId,
mode: 'blended',
});
return result;
} catch (error: any) {
const message =
error.response?.data?.message ?? error.message ?? 'Force ready failed';
this.logger.error(`Force ready failed: ${message}`);
throw new HttpException(message, error.response?.status ?? 502);
}
}
@Post('dispose')
async dispose(
@Body()
body: {
ucid: string;
disposition: string;
callerPhone?: string;
direction?: string;
durationSec?: number;
leadId?: string;
notes?: string;
missedCallId?: string;
},
) {
if (!body.ucid || !body.disposition) {
throw new HttpException('ucid and disposition required', 400);
}
@Post('agent-state')
async agentState(
@Body() body: { state: 'Ready' | 'Pause'; pauseReason?: string },
) {
if (!body.state) {
throw new HttpException('state required', 400);
}
this.logger.log(
`Dispose: ucid=${body.ucid} disposition=${body.disposition}`,
);
this.logger.log(`Agent state change: ${this.defaultAgentId}${body.state} (${body.pauseReason ?? ''})`);
const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);
try {
const result = await this.ozonetelAgent.changeAgentState({
agentId: this.defaultAgentId,
state: body.state,
pauseReason: body.pauseReason,
});
return result;
} catch (error: any) {
const message = error.response?.data?.message ?? error.message ?? 'State change failed';
return { status: 'error', message };
}
// Auto-assign missed call when agent goes Ready
if (body.state === 'Ready') {
try {
const assigned = await this.missedQueue.assignNext(this.defaultAgentId);
if (assigned) {
return { status: 'ok', message: `State changed to Ready. Assigned missed call ${assigned.id}`, assignedCall: assigned };
}
} catch (err) {
this.logger.warn(`Auto-assignment on Ready failed: ${err}`);
}
}
try {
const result = await this.ozonetelAgent.setDisposition({
agentId: this.defaultAgentId,
ucid: body.ucid,
disposition: ozonetelDisposition,
});
} catch (error: any) {
const message =
error.response?.data?.message ?? error.message ?? 'Disposition failed';
this.logger.error(`Dispose failed: ${message}`);
}
@Post('agent-ready')
async agentReady() {
this.logger.log(`Force ready: logging out and back in agent ${this.defaultAgentId}`);
// Handle missed call callback status update
if (body.missedCallId) {
const statusMap: Record<string, string> = {
APPOINTMENT_BOOKED: 'CALLBACK_COMPLETED',
INFO_PROVIDED: 'CALLBACK_COMPLETED',
FOLLOW_UP_SCHEDULED: 'CALLBACK_COMPLETED',
CALLBACK_REQUESTED: 'CALLBACK_COMPLETED',
WRONG_NUMBER: 'WRONG_NUMBER',
};
const newStatus = statusMap[body.disposition];
if (newStatus) {
try {
await this.ozonetelAgent.logoutAgent({
agentId: this.defaultAgentId,
password: this.defaultAgentPassword,
});
const result = await this.ozonetelAgent.loginAgent({
agentId: this.defaultAgentId,
password: this.defaultAgentPassword,
phoneNumber: this.defaultSipId,
mode: 'blended',
});
return result;
} catch (error: any) {
const message = error.response?.data?.message ?? error.message ?? 'Force ready failed';
this.logger.error(`Force ready failed: ${message}`);
throw new HttpException(message, error.response?.status ?? 502);
}
}
@Post('dispose')
async dispose(
@Body() body: {
ucid: string;
disposition: string;
callerPhone?: string;
direction?: string;
durationSec?: number;
leadId?: string;
notes?: string;
missedCallId?: 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,
});
} catch (error: any) {
const message = error.response?.data?.message ?? error.message ?? 'Disposition failed';
this.logger.error(`Dispose failed: ${message}`);
}
// Handle missed call callback status update
if (body.missedCallId) {
const statusMap: Record<string, string> = {
APPOINTMENT_BOOKED: 'CALLBACK_COMPLETED',
INFO_PROVIDED: 'CALLBACK_COMPLETED',
FOLLOW_UP_SCHEDULED: 'CALLBACK_COMPLETED',
CALLBACK_REQUESTED: 'CALLBACK_COMPLETED',
WRONG_NUMBER: 'WRONG_NUMBER',
};
const newStatus = statusMap[body.disposition];
if (newStatus) {
try {
await this.platform.query<any>(
`mutation { updateCall(id: "${body.missedCallId}", data: { callbackstatus: ${newStatus} }) { id } }`,
);
} catch (err) {
this.logger.warn(`Failed to update missed call status: ${err}`);
}
}
}
// Auto-assign next missed call to this agent
try {
await this.missedQueue.assignNext(this.defaultAgentId);
await this.platform.query<any>(
`mutation { updateCall(id: "${body.missedCallId}", data: { callbackstatus: ${newStatus} }) { id } }`,
);
} catch (err) {
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
this.logger.warn(`Failed to update missed call status: ${err}`);
}
return { status: 'ok' };
}
}
@Post('dial')
async dial(
@Body() body: { phoneNumber: string; campaignName?: string; leadId?: string },
) {
if (!body.phoneNumber) {
throw new HttpException('phoneNumber required', 400);
}
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.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, error.response?.status ?? 502);
}
// Auto-assign next missed call to this agent
try {
await this.missedQueue.assignNext(this.defaultAgentId);
} catch (err) {
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
}
@Post('call-control')
async callControl(
@Body() body: {
action: 'CONFERENCE' | 'HOLD' | 'UNHOLD' | 'MUTE' | 'UNMUTE' | 'KICK_CALL';
ucid: string;
conferenceNumber?: string;
},
) {
if (!body.action || !body.ucid) {
throw new HttpException('action and ucid required', 400);
}
if (body.action === 'CONFERENCE' && !body.conferenceNumber) {
throw new HttpException('conferenceNumber required for CONFERENCE action', 400);
}
return { status: 'ok' };
}
this.logger.log(`Call control: ${body.action} ucid=${body.ucid}`);
try {
const result = await this.ozonetelAgent.callControl(body);
return result;
} catch (error: any) {
const message = error.response?.data?.message ?? error.message ?? 'Call control failed';
throw new HttpException(message, error.response?.status ?? 502);
}
@Post('dial')
async dial(
@Body()
body: {
phoneNumber: string;
campaignName?: string;
leadId?: string;
},
) {
if (!body.phoneNumber) {
throw new HttpException('phoneNumber required', 400);
}
@Post('recording')
async recording(
@Body() body: { ucid: string; action: 'pause' | 'unPause' },
) {
if (!body.ucid || !body.action) {
throw new HttpException('ucid and action required', 400);
}
const campaignName =
body.campaignName ??
process.env.OZONETEL_CAMPAIGN_NAME ??
'Inbound_918041763265';
try {
const result = await this.ozonetelAgent.pauseRecording(body);
return result;
} catch (error: any) {
const message = error.response?.data?.message ?? error.message ?? 'Recording control failed';
throw new HttpException(message, error.response?.status ?? 502);
}
this.logger.log(
`Manual dial: ${body.phoneNumber} campaign=${campaignName} (lead: ${body.leadId ?? 'none'})`,
);
try {
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, error.response?.status ?? 502);
}
}
@Post('call-control')
async callControl(
@Body()
body: {
action:
| 'CONFERENCE'
| 'HOLD'
| 'UNHOLD'
| 'MUTE'
| 'UNMUTE'
| 'KICK_CALL';
ucid: string;
conferenceNumber?: string;
},
) {
if (!body.action || !body.ucid) {
throw new HttpException('action and ucid required', 400);
}
if (body.action === 'CONFERENCE' && !body.conferenceNumber) {
throw new HttpException(
'conferenceNumber required for CONFERENCE action',
400,
);
}
@Get('missed-calls')
async missedCalls() {
const result = await this.ozonetelAgent.getAbandonCalls();
return result;
this.logger.log(`Call control: ${body.action} ucid=${body.ucid}`);
try {
const result = await this.ozonetelAgent.callControl(body);
return result;
} catch (error: any) {
const message =
error.response?.data?.message ?? error.message ?? 'Call control failed';
throw new HttpException(message, error.response?.status ?? 502);
}
}
@Post('recording')
async recording(@Body() body: { ucid: string; action: 'pause' | 'unPause' }) {
if (!body.ucid || !body.action) {
throw new HttpException('ucid and action required', 400);
}
@Get('call-history')
async callHistory(
@Query('date') date?: string,
@Query('status') status?: string,
@Query('callType') callType?: string,
) {
const targetDate = date ?? new Date().toISOString().split('T')[0];
this.logger.log(`Call history: date=${targetDate} status=${status ?? 'all'} type=${callType ?? 'all'}`);
try {
const result = await this.ozonetelAgent.pauseRecording(body);
return result;
} catch (error: any) {
const message =
error.response?.data?.message ??
error.message ??
'Recording control failed';
throw new HttpException(message, error.response?.status ?? 502);
}
}
const result = await this.ozonetelAgent.fetchCDR({
date: targetDate,
status,
callType,
});
return result;
@Get('missed-calls')
async missedCalls() {
const result = await this.ozonetelAgent.getAbandonCalls();
return result;
}
@Get('call-history')
async callHistory(
@Query('date') date?: string,
@Query('status') status?: string,
@Query('callType') callType?: string,
) {
const targetDate = date ?? new Date().toISOString().split('T')[0];
this.logger.log(
`Call history: date=${targetDate} status=${status ?? 'all'} type=${callType ?? 'all'}`,
);
const result = await this.ozonetelAgent.fetchCDR({
date: targetDate,
status,
callType,
});
return result;
}
@Get('performance')
async performance(@Query('date') date?: string) {
const targetDate = date ?? new Date().toISOString().split('T')[0];
this.logger.log(
`Performance: date=${targetDate} agent=${this.defaultAgentId}`,
);
const [cdr, summary, aht] = await Promise.all([
this.ozonetelAgent.fetchCDR({ date: targetDate }),
this.ozonetelAgent.getAgentSummary(this.defaultAgentId, targetDate),
this.ozonetelAgent.getAHT(this.defaultAgentId),
]);
const totalCalls = cdr.length;
const inbound = cdr.filter((c: any) => c.Type === 'InBound').length;
const outbound = cdr.filter(
(c: any) => c.Type === 'Manual' || c.Type === 'Progressive',
).length;
const answered = cdr.filter((c: any) => c.Status === 'Answered').length;
const missed = cdr.filter(
(c: any) => c.Status === 'Unanswered' || c.Status === 'NotAnswered',
).length;
const talkTimes = cdr
.filter((c: any) => c.TalkTime && c.TalkTime !== '00:00:00')
.map((c: any) => {
const parts = c.TalkTime.split(':').map(Number);
return parts[0] * 3600 + parts[1] * 60 + parts[2];
});
const avgTalkTimeSec =
talkTimes.length > 0
? Math.round(
talkTimes.reduce((a: number, b: number) => a + b, 0) /
talkTimes.length,
)
: 0;
const dispositions: Record<string, number> = {};
for (const c of cdr) {
const d = (c as any).Disposition || 'No Disposition';
dispositions[d] = (dispositions[d] ?? 0) + 1;
}
@Get('performance')
async performance(@Query('date') date?: string) {
const targetDate = date ?? new Date().toISOString().split('T')[0];
this.logger.log(`Performance: date=${targetDate} agent=${this.defaultAgentId}`);
const appointmentsBooked = cdr.filter((c: any) =>
c.Disposition?.toLowerCase().includes('appointment'),
).length;
const [cdr, summary, aht] = await Promise.all([
this.ozonetelAgent.fetchCDR({ date: targetDate }),
this.ozonetelAgent.getAgentSummary(this.defaultAgentId, targetDate),
this.ozonetelAgent.getAHT(this.defaultAgentId),
]);
return {
date: targetDate,
calls: { total: totalCalls, inbound, outbound, answered, missed },
avgTalkTimeSec,
avgHandlingTime: aht,
conversionRate:
totalCalls > 0
? Math.round((appointmentsBooked / totalCalls) * 100)
: 0,
appointmentsBooked,
timeUtilization: summary,
dispositions,
};
}
const totalCalls = cdr.length;
const inbound = cdr.filter((c: any) => c.Type === 'InBound').length;
const outbound = cdr.filter((c: any) => c.Type === 'Manual' || c.Type === 'Progressive').length;
const answered = cdr.filter((c: any) => c.Status === 'Answered').length;
const missed = cdr.filter((c: any) => c.Status === 'Unanswered' || c.Status === 'NotAnswered').length;
const talkTimes = cdr
.filter((c: any) => c.TalkTime && c.TalkTime !== '00:00:00')
.map((c: any) => {
const parts = c.TalkTime.split(':').map(Number);
return parts[0] * 3600 + parts[1] * 60 + parts[2];
});
const avgTalkTimeSec = talkTimes.length > 0
? Math.round(talkTimes.reduce((a: number, b: number) => a + b, 0) / talkTimes.length)
: 0;
const dispositions: Record<string, number> = {};
for (const c of cdr) {
const d = (c as any).Disposition || 'No Disposition';
dispositions[d] = (dispositions[d] ?? 0) + 1;
}
const appointmentsBooked = cdr.filter((c: any) =>
c.Disposition?.toLowerCase().includes('appointment'),
).length;
return {
date: targetDate,
calls: { total: totalCalls, inbound, outbound, answered, missed },
avgTalkTimeSec,
avgHandlingTime: aht,
conversionRate: totalCalls > 0 ? Math.round((appointmentsBooked / totalCalls) * 100) : 0,
appointmentsBooked,
timeUtilization: summary,
dispositions,
};
}
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';
}
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

@@ -6,9 +6,9 @@ import { WorklistModule } from '../worklist/worklist.module';
import { PlatformModule } from '../platform/platform.module';
@Module({
imports: [PlatformModule, forwardRef(() => WorklistModule)],
controllers: [OzonetelAgentController, KookooIvrController],
providers: [OzonetelAgentService],
exports: [OzonetelAgentService],
imports: [PlatformModule, forwardRef(() => WorklistModule)],
controllers: [OzonetelAgentController, KookooIvrController],
providers: [OzonetelAgentService],
exports: [OzonetelAgentService],
})
export class OzonetelAgentModule {}

View File

@@ -4,463 +4,530 @@ import axios from 'axios';
@Injectable()
export class OzonetelAgentService {
private readonly logger = new Logger(OzonetelAgentService.name);
private readonly apiDomain: string;
private readonly apiKey: string;
private readonly accountId: string;
private cachedToken: string | null = null;
private tokenExpiry: number = 0;
private readonly logger = new Logger(OzonetelAgentService.name);
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';
this.apiKey = config.get<string>('exotel.apiKey') ?? '';
this.accountId = config.get<string>('exotel.accountSid') ?? '';
constructor(private config: ConfigService) {
this.apiDomain =
config.get<string>('exotel.subdomain') ?? 'in1-ccaas-api.ozonetel.com';
this.apiKey = config.get<string>('exotel.apiKey') ?? '';
this.accountId = config.get<string>('exotel.accountSid') ?? '';
}
private async getToken(): Promise<string> {
if (this.cachedToken && Date.now() < this.tokenExpiry) {
return this.cachedToken;
}
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 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 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');
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;
}
async loginAgent(params: {
agentId: string;
password: string;
phoneNumber: string;
mode?: string;
}): Promise<{ status: string; message: string }> {
const url = `https://${this.apiDomain}/CAServices/AgentAuthenticationV2/index.php`;
throw new Error(data.message ?? 'Token generation failed');
}
this.logger.log(`Logging in agent ${params.agentId} with phone ${params.phoneNumber}`);
async loginAgent(params: {
agentId: string;
password: string;
phoneNumber: string;
mode?: string;
}): Promise<{ status: string; message: string }> {
const url = `https://${this.apiDomain}/CAServices/AgentAuthenticationV2/index.php`;
try {
const response = await axios.post(
url,
new URLSearchParams({
userName: this.accountId,
apiKey: this.apiKey,
phoneNumber: params.phoneNumber,
action: 'login',
mode: params.mode ?? 'blended',
state: 'Ready',
}).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
auth: {
username: params.agentId,
password: params.password,
},
},
);
this.logger.log(
`Logging in agent ${params.agentId} with phone ${params.phoneNumber}`,
);
const data = response.data;
try {
const response = await axios.post(
url,
new URLSearchParams({
userName: this.accountId,
apiKey: this.apiKey,
phoneNumber: params.phoneNumber,
action: 'login',
mode: params.mode ?? 'blended',
state: 'Ready',
}).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
auth: {
username: params.agentId,
password: params.password,
},
},
);
// "already logged in" is not a real error — treat as success
if (data.status === 'error' && data.message?.includes('already logged in')) {
this.logger.log(`Agent ${params.agentId} already logged in — treating as success`);
return { status: 'success', message: data.message };
}
const data = response.data;
this.logger.log(`Agent login response: ${JSON.stringify(data)}`);
return data;
} catch (error: any) {
this.logger.error(`Agent login failed: ${error.message}`);
throw error;
}
// "already logged in" is not a real error — treat as success
if (
data.status === 'error' &&
data.message?.includes('already logged in')
) {
this.logger.log(
`Agent ${params.agentId} already logged in — treating as success`,
);
return { status: 'success', message: data.message };
}
this.logger.log(`Agent login response: ${JSON.stringify(data)}`);
return data;
} catch (error: any) {
this.logger.error(`Agent login failed: ${error.message}`);
throw error;
}
}
async manualDial(params: {
agentId: string;
campaignName: string;
customerNumber: string;
}): Promise<{ status: string; ucid?: string; message?: string }> {
const url = `https://${this.apiDomain}/ca_apis/AgentManualDial`;
async manualDial(params: {
agentId: string;
campaignName: string;
customerNumber: string;
}): Promise<{ status: string; ucid?: string; message?: string }> {
const url = `https://${this.apiDomain}/ca_apis/AgentManualDial`;
this.logger.log(`Manual dial: agent=${params.agentId} campaign=${params.campaignName} number=${params.customerNumber}`);
this.logger.log(
`Manual dial: agent=${params.agentId} campaign=${params.campaignName} number=${params.customerNumber}`,
);
try {
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',
},
});
try {
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',
},
},
);
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;
}
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;
}
}
async changeAgentState(params: {
agentId: string;
state: 'Ready' | 'Pause';
pauseReason?: string;
}): Promise<{ status: string; message: string }> {
const url = `https://${this.apiDomain}/ca_apis/changeAgentState`;
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}`);
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;
}
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',
},
});
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) {
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
this.logger.error(`Change agent state failed: ${error.message} ${responseData}`);
throw error;
}
this.logger.log(
`Change agent state response: ${JSON.stringify(response.data)}`,
);
return response.data;
} catch (error: any) {
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';
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}`);
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',
},
});
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;
}
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;
}
}
async callControl(params: {
action: 'CONFERENCE' | 'HOLD' | 'UNHOLD' | 'MUTE' | 'UNMUTE' | 'KICK_CALL';
ucid: string;
conferenceNumber?: string;
}): Promise<{ status: string; message: string; ucid?: string }> {
const url = `https://${this.apiDomain}/ca_apis/CallControl_V4`;
const did = process.env.OZONETEL_DID ?? '918041763265';
const agentPhoneName = process.env.OZONETEL_SIP_ID ?? '523590';
async callControl(params: {
action: 'CONFERENCE' | 'HOLD' | 'UNHOLD' | 'MUTE' | 'UNMUTE' | 'KICK_CALL';
ucid: string;
conferenceNumber?: string;
}): Promise<{ status: string; message: string; ucid?: string }> {
const url = `https://${this.apiDomain}/ca_apis/CallControl_V4`;
const did = process.env.OZONETEL_DID ?? '918041763265';
const agentPhoneName = process.env.OZONETEL_SIP_ID ?? '523590';
this.logger.log(`Call control: action=${params.action} ucid=${params.ucid} conference=${params.conferenceNumber ?? 'none'}`);
this.logger.log(
`Call control: action=${params.action} ucid=${params.ucid} conference=${params.conferenceNumber ?? 'none'}`,
);
try {
const token = await this.getToken();
const body: Record<string, string> = {
userName: this.accountId,
action: params.action,
ucid: params.ucid,
did,
agentPhoneName,
};
if (params.conferenceNumber) {
body.conferenceNumber = params.conferenceNumber;
}
try {
const token = await this.getToken();
const body: Record<string, string> = {
userName: this.accountId,
action: params.action,
ucid: params.ucid,
did,
agentPhoneName,
};
if (params.conferenceNumber) {
body.conferenceNumber = params.conferenceNumber;
}
const response = await axios.post(url, body, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
const response = await axios.post(url, body, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
this.logger.log(`Call control response: ${JSON.stringify(response.data)}`);
return response.data;
} catch (error: any) {
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
this.logger.error(`Call control failed: ${error.message} ${responseData}`);
throw error;
}
this.logger.log(
`Call control response: ${JSON.stringify(response.data)}`,
);
return response.data;
} catch (error: any) {
const responseData = error?.response?.data
? JSON.stringify(error.response.data)
: '';
this.logger.error(
`Call control failed: ${error.message} ${responseData}`,
);
throw error;
}
}
async pauseRecording(params: {
ucid: string;
action: 'pause' | 'unPause';
}): Promise<{ status: string; message: string }> {
const url = `https://${this.apiDomain}/CAServices/Call/Record.php`;
async pauseRecording(params: {
ucid: string;
action: 'pause' | 'unPause';
}): Promise<{ status: string; message: string }> {
const url = `https://${this.apiDomain}/CAServices/Call/Record.php`;
this.logger.log(`Recording ${params.action}: ucid=${params.ucid}`);
this.logger.log(`Recording ${params.action}: ucid=${params.ucid}`);
try {
const response = await axios.get(url, {
params: {
userName: this.accountId,
apiKey: this.apiKey,
action: params.action,
ucid: params.ucid,
},
});
try {
const response = await axios.get(url, {
params: {
userName: this.accountId,
apiKey: this.apiKey,
action: params.action,
ucid: params.ucid,
},
});
this.logger.log(`Recording control response: ${JSON.stringify(response.data)}`);
return response.data;
} catch (error: any) {
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
this.logger.error(`Recording control failed: ${error.message} ${responseData}`);
throw error;
}
this.logger.log(
`Recording control response: ${JSON.stringify(response.data)}`,
);
return response.data;
} catch (error: any) {
const responseData = error?.response?.data
? JSON.stringify(error.response.data)
: '';
this.logger.error(
`Recording control failed: ${error.message} ${responseData}`,
);
throw error;
}
}
async getAbandonCalls(params?: {
fromTime?: string;
toTime?: string;
campaignName?: string;
}): Promise<Array<{
monitorUCID: string;
type: string;
status: string;
campaign: string;
callerID: string;
did: string;
agentID: string;
agent: string;
hangupBy: string;
callTime: string;
}>> {
const url = `https://${this.apiDomain}/ca_apis/abandonCalls`;
async getAbandonCalls(params?: {
fromTime?: string;
toTime?: string;
campaignName?: string;
}): Promise<
Array<{
monitorUCID: string;
type: string;
status: string;
campaign: string;
callerID: string;
did: string;
agentID: string;
agent: string;
hangupBy: string;
callTime: string;
}>
> {
const url = `https://${this.apiDomain}/ca_apis/abandonCalls`;
this.logger.log('Fetching abandon calls');
this.logger.log('Fetching abandon calls');
try {
const token = await this.getToken();
const body: Record<string, string> = { userName: this.accountId };
if (params?.fromTime) body.fromTime = params.fromTime;
if (params?.toTime) body.toTime = params.toTime;
if (params?.campaignName) body.campaignName = params.campaignName;
try {
const token = await this.getToken();
const body: Record<string, string> = { userName: this.accountId };
if (params?.fromTime) body.fromTime = params.fromTime;
if (params?.toTime) body.toTime = params.toTime;
if (params?.campaignName) body.campaignName = params.campaignName;
const response = await axios({
method: 'GET',
url,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: JSON.stringify(body),
});
const response = await axios({
method: 'GET',
url,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: JSON.stringify(body),
});
const data = response.data;
this.logger.log(`Abandon calls response: ${JSON.stringify(data).substring(0, 200)}`);
if (data.status === 'success' && Array.isArray(data.message)) {
return data.message;
}
return [];
} catch (error: any) {
this.logger.error(`Abandon calls failed: ${error.message}`);
return [];
}
const data = response.data;
this.logger.log(
`Abandon calls response: ${JSON.stringify(data).substring(0, 200)}`,
);
if (data.status === 'success' && Array.isArray(data.message)) {
return data.message;
}
return [];
} catch (error: any) {
this.logger.error(`Abandon calls failed: ${error.message}`);
return [];
}
}
async fetchCDR(params: {
date: string; // YYYY-MM-DD
campaignName?: string;
status?: string;
callType?: string;
}): Promise<Array<Record<string, any>>> {
const url = `https://${this.apiDomain}/ca_reports/fetchCDRDetails`;
async fetchCDR(params: {
date: string; // YYYY-MM-DD
campaignName?: string;
status?: string;
callType?: string;
}): Promise<Array<Record<string, any>>> {
const url = `https://${this.apiDomain}/ca_reports/fetchCDRDetails`;
this.logger.log(`Fetch CDR: date=${params.date}`);
this.logger.log(`Fetch CDR: date=${params.date}`);
try {
const token = await this.getToken();
const body: Record<string, string> = {
userName: this.accountId,
fromDate: `${params.date} 00:00:00`,
toDate: `${params.date} 23:59:59`,
};
if (params.campaignName) body.campaignName = params.campaignName;
if (params.status) body.status = params.status;
if (params.callType) body.callType = params.callType;
try {
const token = await this.getToken();
const body: Record<string, string> = {
userName: this.accountId,
fromDate: `${params.date} 00:00:00`,
toDate: `${params.date} 23:59:59`,
};
if (params.campaignName) body.campaignName = params.campaignName;
if (params.status) body.status = params.status;
if (params.callType) body.callType = params.callType;
const response = await axios({
method: 'GET',
url,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: JSON.stringify(body),
});
const response = await axios({
method: 'GET',
url,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: JSON.stringify(body),
});
const data = response.data;
if (data.status === 'success' && Array.isArray(data.details)) {
return data.details;
}
return [];
} catch (error: any) {
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
this.logger.error(`Fetch CDR failed: ${error.message} ${responseData}`);
return [];
}
const data = response.data;
if (data.status === 'success' && Array.isArray(data.details)) {
return data.details;
}
return [];
} catch (error: any) {
const responseData = error?.response?.data
? JSON.stringify(error.response.data)
: '';
this.logger.error(`Fetch CDR failed: ${error.message} ${responseData}`);
return [];
}
}
async getAgentSummary(agentId: string, date: string): Promise<{
totalLoginDuration: string;
totalBusyTime: string;
totalIdleTime: string;
totalPauseTime: string;
totalWrapupTime: string;
totalDialTime: string;
} | null> {
const url = `https://${this.apiDomain}/ca_reports/summaryReport`;
async getAgentSummary(
agentId: string,
date: string,
): Promise<{
totalLoginDuration: string;
totalBusyTime: string;
totalIdleTime: string;
totalPauseTime: string;
totalWrapupTime: string;
totalDialTime: string;
} | null> {
const url = `https://${this.apiDomain}/ca_reports/summaryReport`;
try {
const token = await this.getToken();
const response = await axios({
method: 'GET',
url,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: JSON.stringify({
userName: this.accountId,
agentId,
fromDate: `${date} 00:00:00`,
toDate: `${date} 23:59:59`,
}),
});
try {
const token = await this.getToken();
const response = await axios({
method: 'GET',
url,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: JSON.stringify({
userName: this.accountId,
agentId,
fromDate: `${date} 00:00:00`,
toDate: `${date} 23:59:59`,
}),
});
const data = response.data;
if (data.status === 'success' && data.message) {
const record = Array.isArray(data.message) ? data.message[0] : data.message;
return {
totalLoginDuration: record.TotalLoginDuration ?? '00:00:00',
totalBusyTime: record.TotalBusyTime ?? '00:00:00',
totalIdleTime: record.TotalIdleTime ?? '00:00:00',
totalPauseTime: record.TotalPauseTime ?? '00:00:00',
totalWrapupTime: record.TotalWrapupTime ?? '00:00:00',
totalDialTime: record.TotalDialTime ?? '00:00:00',
};
}
return null;
} catch (error: any) {
this.logger.error(`Agent summary failed: ${error.message}`);
return null;
}
const data = response.data;
if (data.status === 'success' && data.message) {
const record = Array.isArray(data.message)
? data.message[0]
: data.message;
return {
totalLoginDuration: record.TotalLoginDuration ?? '00:00:00',
totalBusyTime: record.TotalBusyTime ?? '00:00:00',
totalIdleTime: record.TotalIdleTime ?? '00:00:00',
totalPauseTime: record.TotalPauseTime ?? '00:00:00',
totalWrapupTime: record.TotalWrapupTime ?? '00:00:00',
totalDialTime: record.TotalDialTime ?? '00:00:00',
};
}
return null;
} catch (error: any) {
this.logger.error(`Agent summary failed: ${error.message}`);
return null;
}
}
async getAHT(agentId: string): Promise<string> {
const url = `https://${this.apiDomain}/ca_apis/aht`;
async getAHT(agentId: string): Promise<string> {
const url = `https://${this.apiDomain}/ca_apis/aht`;
try {
const token = await this.getToken();
const response = await axios({
method: 'GET',
url,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: JSON.stringify({
userName: this.accountId,
agentId,
}),
});
try {
const token = await this.getToken();
const response = await axios({
method: 'GET',
url,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: JSON.stringify({
userName: this.accountId,
agentId,
}),
});
const data = response.data;
if (data.status === 'success') {
return data.AHT ?? '00:00:00';
}
return '00:00:00';
} catch (error: any) {
this.logger.error(`AHT failed: ${error.message}`);
return '00:00:00';
}
const data = response.data;
if (data.status === 'success') {
return data.AHT ?? '00:00:00';
}
return '00:00:00';
} catch (error: any) {
this.logger.error(`AHT failed: ${error.message}`);
return '00:00:00';
}
}
async logoutAgent(params: {
agentId: string;
password: string;
}): Promise<{ status: string; message: string }> {
const url = `https://${this.apiDomain}/CAServices/AgentAuthenticationV2/index.php`;
async logoutAgent(params: {
agentId: string;
password: string;
}): Promise<{ status: string; message: string }> {
const url = `https://${this.apiDomain}/CAServices/AgentAuthenticationV2/index.php`;
this.logger.log(`Logging out agent ${params.agentId}`);
this.logger.log(`Logging out agent ${params.agentId}`);
try {
const response = await axios.post(
url,
new URLSearchParams({
userName: this.accountId,
apiKey: this.apiKey,
action: 'logout',
mode: 'blended',
state: 'Ready',
}).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
auth: {
username: params.agentId,
password: params.password,
},
},
);
try {
const response = await axios.post(
url,
new URLSearchParams({
userName: this.accountId,
apiKey: this.apiKey,
action: 'logout',
mode: 'blended',
state: 'Ready',
}).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
auth: {
username: params.agentId,
password: params.password,
},
},
);
this.logger.log(`Agent logout response: ${JSON.stringify(response.data)}`);
return response.data;
} catch (error: any) {
this.logger.error(`Agent logout failed: ${error.message}`);
throw error;
}
this.logger.log(
`Agent logout response: ${JSON.stringify(response.data)}`,
);
return response.data;
} catch (error: any) {
this.logger.error(`Agent logout failed: ${error.message}`);
throw error;
}
}
}