Files
helix-engage-server/src/ozonetel/ozonetel-agent.service.ts
saridsa2 619e9ab405 feat(onboarding/phase-1): admin-editable telephony, ai, and setup-state config
Phase 1 of hospital onboarding & self-service plan
(docs/superpowers/plans/2026-04-06-hospital-onboarding-self-service.md).

Backend foundations to support the upcoming staff-portal Settings hub and
6-step setup wizard. No frontend in this phase.

New config services (mirroring ThemeService / WidgetConfigService):
- SetupStateService    — tracks completion of 6 wizard steps; isWizardRequired()
                         drives the post-login redirect
- TelephonyConfigService — Ozonetel + Exotel + SIP, replaces 8 env vars,
                           seeds from env on first boot, masks secrets on GET,
                           '***masked***' sentinel on PUT means "keep existing"
- AiConfigService      — provider, model, temperature, system prompt addendum;
                         API keys remain in env

New endpoints under /api/config:
- GET  /api/config/setup-state                returns state + wizardRequired flag
- PUT  /api/config/setup-state/steps/:step    mark step complete/incomplete
- POST /api/config/setup-state/dismiss        dismiss wizard
- POST /api/config/setup-state/reset
- GET  /api/config/telephony                  masked
- PUT  /api/config/telephony
- POST /api/config/telephony/reset
- GET  /api/config/ai
- PUT  /api/config/ai
- POST /api/config/ai/reset

ConfigThemeModule is now @Global() so the new sidecar config services are
injectable from AuthModule, OzonetelAgentModule, MaintModule without creating
a circular dependency (ConfigThemeModule already imports AuthModule for
SessionService).

Migrated 11 env-var read sites to use the new services:
- ozonetel-agent.service: exotel API + ozonetel did/sipId via read-through getters
- ozonetel-agent.controller: defaultAgentId/Password/SipId via getters
- kookoo-ivr.controller: sipId/callerId via getters
- auth.controller: OZONETEL_AGENT_PASSWORD (login + logout)
- agent-config.service: sipDomain/wsPort/campaignName via getters
- maint.controller: forceReady + unlockAgent
- ai-provider: createAiModel and isAiConfigured refactored to pure factories
  taking AiProviderOpts; no more ConfigService dependency
- widget-chat.service, recordings.service, ai-enrichment.service,
  ai-chat.controller, ai-insight.consumer, call-assist.service: each builds
  the AI model from AiConfigService.getConfig() + ConfigService API keys

Hot-reload guarantee: every consumer reads via a getter or builds per-call,
so admin updates take effect without sidecar restart. WidgetChatService
specifically rebuilds the model on each streamReply().

Bug fix bundled: dropped widget.json.hospitalName field (the original
duplicate that started this whole thread). WidgetConfigService now reads
brand.hospitalName from ThemeService at the 2 generateKey call sites.
Single source of truth for hospital name is workspace branding.

First-boot env seeding: TelephonyConfigService and AiConfigService both
copy their respective env vars into a fresh data/*.json on onModuleInit if
the file doesn't exist. Existing deployments auto-migrate without manual
intervention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:02:07 +05:30

512 lines
19 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
import { TelephonyConfigService } from '../config/telephony-config.service';
@Injectable()
export class OzonetelAgentService {
private readonly logger = new Logger(OzonetelAgentService.name);
private cachedToken: string | null = null;
private tokenExpiry: number = 0;
constructor(private telephony: TelephonyConfigService) {}
// Read-through getters so admin updates to telephony.json take effect
// immediately without a sidecar restart. The default for apiDomain is
// preserved here because the legacy env-var path used a different default
// ('in1-ccaas-api.ozonetel.com') than the rest of the Exotel config.
private get apiDomain(): string {
return this.telephony.getConfig().exotel.subdomain || 'in1-ccaas-api.ozonetel.com';
}
private get apiKey(): string {
return this.telephony.getConfig().exotel.apiKey;
}
private get accountId(): string {
return this.telephony.getConfig().exotel.accountSid;
}
private async getToken(): Promise<string> {
if (this.cachedToken && Date.now() < this.tokenExpiry) {
return this.cachedToken;
}
return this.refreshToken();
}
async refreshToken(): Promise<string> {
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() + 10 * 60 * 1000; // 10 min cache (Ozonetel expires in ~15 min)
this.logger.log('CloudAgent token generated successfully');
return data.token;
}
throw new Error(data.message ?? 'Token generation failed');
}
private invalidateToken(): void {
this.cachedToken = null;
this.tokenExpiry = 0;
}
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`;
this.logger.log(`Logging in agent ${params.agentId} with phone ${params.phoneNumber}`);
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,
},
},
);
const data = response.data;
// "already logged in" — force logout + re-login to refresh SIP phone mapping
if (data.status === 'error' && data.message?.includes('already logged in')) {
this.logger.log(`Agent ${params.agentId} already logged in — forcing logout + re-login`);
try {
await this.logoutAgent({ agentId: params.agentId, password: params.password });
const retryResponse = 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(`Agent re-login response: ${JSON.stringify(retryResponse.data)}`);
return retryResponse.data;
} catch (retryErr: any) {
this.logger.error(`Agent re-login failed: ${retryErr.message}`);
return { status: 'success', message: 'Re-login attempted' };
}
}
this.logger.log(`Agent login response: ${JSON.stringify(data)}`);
return data;
} catch (error: any) {
if (error?.response?.status === 401) this.invalidateToken();
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`;
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',
},
});
this.logger.log(`Manual dial response: ${JSON.stringify(response.data)}`);
return response.data;
} catch (error: any) {
if (error?.response?.status === 401) this.invalidateToken();
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`;
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) {
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 = this.telephony.getConfig().ozonetel.did;
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;
}
}
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 tcfg = this.telephony.getConfig().ozonetel;
const did = tcfg.did;
const agentPhoneName = tcfg.sipId;
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;
}
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;
}
}
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}`);
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;
}
}
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');
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 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) {
if (error?.response?.status === 401) this.invalidateToken();
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`;
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;
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 [];
}
}
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`,
}),
});
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) {
if (error?.response?.status === 401) this.invalidateToken();
this.logger.error(`Agent summary failed: ${error.message}`);
return null;
}
}
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,
}),
});
const data = response.data;
if (data.status === 'success') {
return data.AHT ?? '00:00:00';
}
return '00:00:00';
} catch (error: any) {
if (error?.response?.status === 401) this.invalidateToken();
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`;
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,
},
},
);
this.logger.log(`Agent logout response: ${JSON.stringify(response.data)}`);
return response.data;
} catch (error: any) {
if (error?.response?.status === 401) this.invalidateToken();
this.logger.error(`Agent logout failed: ${error.message}`);
throw error;
}
}
}