@campxdev/campx-web-utils 2.0.13 → 2.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/types/src/components/Exotel/CallButton.d.ts +10 -0
- package/dist/cjs/types/src/components/Exotel/CallDispositionForm.d.ts +29 -0
- package/dist/cjs/types/src/components/Exotel/ExotelPhone.d.ts +10 -0
- package/dist/cjs/types/src/components/Exotel/ExotelWrapper.d.ts +14 -0
- package/dist/cjs/types/src/components/Exotel/MicrophonePermission.d.ts +6 -0
- package/dist/cjs/types/src/components/Exotel/index.d.ts +6 -0
- package/dist/cjs/types/src/config/index.d.ts +1 -0
- package/dist/cjs/types/src/config/voip.config.d.ts +18 -0
- package/dist/cjs/types/src/constants/exotel.constants.d.ts +7 -0
- package/dist/cjs/types/src/providers/ExotelProvider.d.ts +79 -0
- package/dist/cjs/types/src/providers/VoIPProvider.d.ts +33 -0
- package/dist/cjs/types/src/providers/index.d.ts +2 -0
- package/dist/cjs/types/src/services/crypto/CryptoService.d.ts +23 -0
- package/dist/cjs/types/src/services/exotel/ExotelService.d.ts +47 -0
- package/dist/cjs/types/src/services/exotel/api.d.ts +158 -0
- package/dist/cjs/types/src/services/exotel/index.d.ts +2 -0
- package/dist/cjs/types/src/utils/exotel/formatters.d.ts +8 -0
- package/dist/cjs/types/src/utils/exotel/index.d.ts +1 -0
- package/dist/esm/index.js +2 -2
- package/dist/esm/types/src/components/Exotel/CallButton.d.ts +10 -0
- package/dist/esm/types/src/components/Exotel/CallDispositionForm.d.ts +29 -0
- package/dist/esm/types/src/components/Exotel/ExotelPhone.d.ts +10 -0
- package/dist/esm/types/src/components/Exotel/ExotelWrapper.d.ts +14 -0
- package/dist/esm/types/src/components/Exotel/MicrophonePermission.d.ts +6 -0
- package/dist/esm/types/src/components/Exotel/index.d.ts +6 -0
- package/dist/esm/types/src/config/index.d.ts +1 -0
- package/dist/esm/types/src/config/voip.config.d.ts +18 -0
- package/dist/esm/types/src/constants/exotel.constants.d.ts +7 -0
- package/dist/esm/types/src/providers/ExotelProvider.d.ts +79 -0
- package/dist/esm/types/src/providers/VoIPProvider.d.ts +33 -0
- package/dist/esm/types/src/providers/index.d.ts +2 -0
- package/dist/esm/types/src/services/crypto/CryptoService.d.ts +23 -0
- package/dist/esm/types/src/services/exotel/ExotelService.d.ts +47 -0
- package/dist/esm/types/src/services/exotel/api.d.ts +158 -0
- package/dist/esm/types/src/services/exotel/index.d.ts +2 -0
- package/dist/esm/types/src/utils/exotel/formatters.d.ts +8 -0
- package/dist/esm/types/src/utils/exotel/index.d.ts +1 -0
- package/dist/index.d.ts +357 -3
- package/dist/styles.css +337 -47
- package/dist/types/exotel-crm-websdk.d.ts +46 -0
- package/export.ts +6 -0
- package/package.json +4 -1
- package/src/components/Exotel/CallButton.tsx +164 -0
- package/src/components/Exotel/CallDispositionForm.tsx +213 -0
- package/src/components/Exotel/ExotelPhone.tsx +482 -0
- package/src/components/Exotel/ExotelWrapper.tsx +80 -0
- package/src/components/Exotel/MicrophonePermission.tsx +97 -0
- package/src/components/Exotel/index.ts +10 -0
- package/src/config/index.ts +1 -0
- package/src/config/voip.config.ts +26 -0
- package/src/constants/exotel.constants.ts +7 -0
- package/src/providers/ExotelProvider.tsx +526 -0
- package/src/providers/VoIPProvider.tsx +143 -0
- package/src/providers/index.ts +2 -0
- package/src/selectors/ResearchStageSelector.tsx +1 -0
- package/src/services/crypto/CryptoService.ts +112 -0
- package/src/services/exotel/ExotelService.ts +238 -0
- package/src/services/exotel/api.ts +319 -0
- package/src/services/exotel/index.ts +2 -0
- package/src/utils/exotel/formatters.ts +17 -0
- package/src/utils/exotel/index.ts +1 -0
- package/types/exotel-crm-websdk.d.ts +46 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { syncScrypt } from 'scrypt-js'
|
|
2
|
+
|
|
3
|
+
class CryptoService {
|
|
4
|
+
private static instance: CryptoService
|
|
5
|
+
private readonly algorithm = 'aes-256-cbc'
|
|
6
|
+
private secretKey: Uint8Array | null = null
|
|
7
|
+
private initialized = false
|
|
8
|
+
|
|
9
|
+
private constructor() {
|
|
10
|
+
// Lazy initialization - don't run heavy crypto operations at module load time
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
private ensureInitialized(): void {
|
|
14
|
+
if (this.initialized) return
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const key = '431fef99-47dc-4d2a-9175-2e8f19b20225'
|
|
18
|
+
if (key) {
|
|
19
|
+
// Same as backend: crypto.scryptSync(key, 'salt', 32)
|
|
20
|
+
// scrypt-js with N=16384, r=8, p=1 matches Node.js defaults
|
|
21
|
+
const keyBuffer = new TextEncoder().encode(key)
|
|
22
|
+
const saltBuffer = new TextEncoder().encode('salt')
|
|
23
|
+
this.secretKey = syncScrypt(keyBuffer, saltBuffer, 16384, 8, 1, 32)
|
|
24
|
+
}
|
|
25
|
+
this.initialized = true
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error('CryptoService initialization failed:', error)
|
|
28
|
+
this.initialized = true // Prevent repeated attempts
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static getInstance(): CryptoService {
|
|
33
|
+
if (!CryptoService.instance) {
|
|
34
|
+
CryptoService.instance = new CryptoService()
|
|
35
|
+
}
|
|
36
|
+
return CryptoService.instance
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Decrypt data using AES-256-CBC (same as backend)
|
|
41
|
+
* Format: "iv_hex:encrypted_hex"
|
|
42
|
+
*/
|
|
43
|
+
decryptData(encryptedData: string): string {
|
|
44
|
+
this.ensureInitialized()
|
|
45
|
+
|
|
46
|
+
if (!encryptedData || !this.secretKey) {
|
|
47
|
+
console.error('Missing encrypted data or secret key')
|
|
48
|
+
return ''
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const [ivHex, encrypted] = encryptedData.split(':')
|
|
53
|
+
if (!ivHex || !encrypted) {
|
|
54
|
+
console.error('Invalid encrypted data format')
|
|
55
|
+
return ''
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Use require for crypto to avoid bundler issues with top-level imports
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
60
|
+
const crypto = require('crypto')
|
|
61
|
+
|
|
62
|
+
const iv = Buffer.from(ivHex, 'hex')
|
|
63
|
+
const decipher = crypto.createDecipheriv(
|
|
64
|
+
this.algorithm,
|
|
65
|
+
Buffer.from(this.secretKey),
|
|
66
|
+
iv,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
|
70
|
+
decrypted += decipher.final('utf8')
|
|
71
|
+
|
|
72
|
+
return decrypted
|
|
73
|
+
} catch (error) {
|
|
74
|
+
console.error('Decryption failed:', error)
|
|
75
|
+
return ''
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Mask mobile number for display (first 2 and last 2 of actual number visible)
|
|
81
|
+
* Handles country code prefix (+91)
|
|
82
|
+
* e.g., "+919876543210" -> "+9198******10"
|
|
83
|
+
* e.g., "9876543210" -> "98******10"
|
|
84
|
+
*/
|
|
85
|
+
maskMobile(mobile: string): string {
|
|
86
|
+
if (!mobile || mobile.length < 4) return '**********'
|
|
87
|
+
|
|
88
|
+
// Check for country code prefix
|
|
89
|
+
let prefix = ''
|
|
90
|
+
let number = mobile
|
|
91
|
+
|
|
92
|
+
if (mobile.startsWith('+91')) {
|
|
93
|
+
prefix = '+91'
|
|
94
|
+
number = mobile.substring(3)
|
|
95
|
+
} else if (mobile.startsWith('91') && mobile.length > 10) {
|
|
96
|
+
prefix = '91'
|
|
97
|
+
number = mobile.substring(2)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const cleaned = number.replace(/\D/g, '')
|
|
101
|
+
if (cleaned.length < 4) return prefix + '**********'
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
prefix +
|
|
105
|
+
cleaned.substring(0, 2) +
|
|
106
|
+
'*'.repeat(cleaned.length - 4) +
|
|
107
|
+
cleaned.substring(cleaned.length - 2)
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export default CryptoService.getInstance()
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import ExotelCRMWebSDK, {
|
|
2
|
+
CallEventData,
|
|
3
|
+
ExotelWebPhoneSDK,
|
|
4
|
+
} from '@exotel-npm-dev/exotel-ip-calling-crm-websdk';
|
|
5
|
+
|
|
6
|
+
export type PermissionStatus = 'granted' | 'denied' | 'prompt' | 'unknown';
|
|
7
|
+
|
|
8
|
+
class ExotelService {
|
|
9
|
+
private static instance: ExotelService;
|
|
10
|
+
private exotelSDK: ExotelCRMWebSDK | null = null;
|
|
11
|
+
private webPhone: ExotelWebPhoneSDK | null = null;
|
|
12
|
+
private callListeners: Set<(event: string, data: CallEventData) => void> =
|
|
13
|
+
new Set();
|
|
14
|
+
private registerListeners: Set<(state: string) => void> = new Set();
|
|
15
|
+
private sessionListeners: Set<(state: string, data: any) => void> = new Set();
|
|
16
|
+
private permissionStatus: PermissionStatus = 'unknown';
|
|
17
|
+
|
|
18
|
+
public static getInstance(): ExotelService {
|
|
19
|
+
if (!ExotelService.instance) {
|
|
20
|
+
ExotelService.instance = new ExotelService();
|
|
21
|
+
}
|
|
22
|
+
return ExotelService.instance;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Initialize the Exotel SDK
|
|
27
|
+
* @param accessToken - The access token for authentication
|
|
28
|
+
* @param agentUserId - The user ID of the agent
|
|
29
|
+
* @param autoConnectVOIP - Whether to automatically connect VOIP (default: true)
|
|
30
|
+
* @returns boolean indicating success
|
|
31
|
+
*/
|
|
32
|
+
public async initialize(
|
|
33
|
+
accessToken: string,
|
|
34
|
+
agentUserId: string,
|
|
35
|
+
autoConnectVOIP = true,
|
|
36
|
+
): Promise<boolean> {
|
|
37
|
+
try {
|
|
38
|
+
await this.checkAudioPermissions();
|
|
39
|
+
|
|
40
|
+
this.exotelSDK = new ExotelCRMWebSDK(
|
|
41
|
+
accessToken,
|
|
42
|
+
agentUserId,
|
|
43
|
+
autoConnectVOIP,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const phone = await this.exotelSDK.Initialize(
|
|
47
|
+
this.handleCallEvent,
|
|
48
|
+
this.handleRegisterEvent,
|
|
49
|
+
this.handleSessionEvent,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
this.webPhone = phone || null;
|
|
53
|
+
|
|
54
|
+
return !!this.webPhone;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error('Failed to initialize Exotel SDK:', error);
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public async checkAudioPermissions(): Promise<boolean> {
|
|
62
|
+
try {
|
|
63
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
64
|
+
const hasAudioDevice = devices.some(
|
|
65
|
+
(device) => device.kind === 'audioinput',
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
if (!hasAudioDevice) {
|
|
69
|
+
console.error('No audio input devices found');
|
|
70
|
+
this.permissionStatus = 'denied';
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (navigator.permissions && navigator.permissions.query) {
|
|
75
|
+
try {
|
|
76
|
+
const permissionStatus = await navigator.permissions.query({
|
|
77
|
+
name: 'microphone' as PermissionName,
|
|
78
|
+
});
|
|
79
|
+
this.permissionStatus = permissionStatus.state as
|
|
80
|
+
| 'granted'
|
|
81
|
+
| 'denied'
|
|
82
|
+
| 'prompt';
|
|
83
|
+
|
|
84
|
+
permissionStatus.onchange = () => {
|
|
85
|
+
this.permissionStatus = permissionStatus.state as
|
|
86
|
+
| 'granted'
|
|
87
|
+
| 'denied'
|
|
88
|
+
| 'prompt';
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return permissionStatus.state === 'granted';
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.warn('Could not query permission status:', error);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return true;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
console.error('Error checking audio permissions:', error);
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public async requestMicrophoneAccess(): Promise<boolean> {
|
|
105
|
+
try {
|
|
106
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
107
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
108
|
+
this.permissionStatus = 'granted';
|
|
109
|
+
return true;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error('Failed to get microphone access:', error);
|
|
112
|
+
this.permissionStatus = 'denied';
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
public makeCall(phoneNumber: string): Promise<any> {
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
if (!this.webPhone) {
|
|
120
|
+
reject(new Error('Web phone not initialized'));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
this.webPhone.MakeCall(phoneNumber, (status, data) => {
|
|
125
|
+
if (status === 'success') {
|
|
126
|
+
resolve(data);
|
|
127
|
+
} else {
|
|
128
|
+
reject(data);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
public async acceptCall(): Promise<void> {
|
|
135
|
+
if (!this.webPhone) {
|
|
136
|
+
throw new Error('Web phone not initialized');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (this.permissionStatus !== 'granted') {
|
|
140
|
+
const granted = await this.requestMicrophoneAccess();
|
|
141
|
+
if (!granted) {
|
|
142
|
+
throw new Error('Microphone access denied');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this.webPhone.AcceptCall();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
public hangupCall(): void {
|
|
150
|
+
this.webPhone?.HangupCall();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Reject an incoming call before accepting it.
|
|
155
|
+
* Note: Exotel SDK only has HangupCall - no separate RejectCall method.
|
|
156
|
+
* HangupCall internally calls webrtcSIPPhone.rejectCall() -> phone.sipHangUp()
|
|
157
|
+
*/
|
|
158
|
+
public rejectCall(): void {
|
|
159
|
+
this.webPhone?.HangupCall();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
public toggleHold(): void {
|
|
163
|
+
this.webPhone?.ToggleHold();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
public toggleMute(): void {
|
|
167
|
+
this.webPhone?.ToggleMute();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
public sendDTMF(digit: string): void {
|
|
171
|
+
this.webPhone?.SendDTMF(digit);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
public async registerDevice(): Promise<void> {
|
|
175
|
+
if (this.permissionStatus !== 'granted') {
|
|
176
|
+
const granted = await this.requestMicrophoneAccess();
|
|
177
|
+
if (!granted) {
|
|
178
|
+
throw new Error('Microphone access denied');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
this.webPhone?.RegisterDevice();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
public unregisterDevice(): void {
|
|
186
|
+
this.webPhone?.UnRegisterDevice();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
public getPermissionStatus(): PermissionStatus {
|
|
190
|
+
return this.permissionStatus;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private handleCallEvent = (event: string, callData: CallEventData): void => {
|
|
194
|
+
this.callListeners.forEach((listener) => listener(event, callData));
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
private handleRegisterEvent = (state: string): void => {
|
|
198
|
+
this.registerListeners.forEach((listener) => listener(state));
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
private handleSessionEvent = (state: string, data: any): void => {
|
|
202
|
+
this.sessionListeners.forEach((listener) => listener(state, data));
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
public addCallListener(
|
|
206
|
+
listener: (event: string, data: CallEventData) => void,
|
|
207
|
+
): void {
|
|
208
|
+
this.callListeners.add(listener);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
public removeCallListener(
|
|
212
|
+
listener: (event: string, data: CallEventData) => void,
|
|
213
|
+
): void {
|
|
214
|
+
this.callListeners.delete(listener);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
public addRegisterListener(listener: (state: string) => void): void {
|
|
218
|
+
this.registerListeners.add(listener);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
public removeRegisterListener(listener: (state: string) => void): void {
|
|
222
|
+
this.registerListeners.delete(listener);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
public addSessionListener(
|
|
226
|
+
listener: (state: string, data: any) => void,
|
|
227
|
+
): void {
|
|
228
|
+
this.sessionListeners.add(listener);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
public removeSessionListener(
|
|
232
|
+
listener: (state: string, data: any) => void,
|
|
233
|
+
): void {
|
|
234
|
+
this.sessionListeners.delete(listener);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export default ExotelService;
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { axios } from '../../config/axios'
|
|
2
|
+
import { workspace } from '../../utils/constants'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Voice config from notification-configs API
|
|
6
|
+
*/
|
|
7
|
+
export interface VoiceConfig {
|
|
8
|
+
id: string
|
|
9
|
+
isEnabled: boolean
|
|
10
|
+
name: string
|
|
11
|
+
channelType: string
|
|
12
|
+
EXOTEL?: {
|
|
13
|
+
appId: string
|
|
14
|
+
appSecret: string
|
|
15
|
+
token?: string
|
|
16
|
+
tokenExpiresAt?: string
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Notification config response structure
|
|
22
|
+
*/
|
|
23
|
+
export interface NotificationConfigResponse {
|
|
24
|
+
config: {
|
|
25
|
+
id: string
|
|
26
|
+
tenantId: string
|
|
27
|
+
institutionUniqueId: number
|
|
28
|
+
enableVoice?: boolean
|
|
29
|
+
voiceConfigs?: VoiceConfig[]
|
|
30
|
+
// ... other fields not relevant to VoIP
|
|
31
|
+
}
|
|
32
|
+
options: {
|
|
33
|
+
voiceChannels?: string[]
|
|
34
|
+
// ... other fields
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check if VoIP is enabled for the current tenant by fetching notification config.
|
|
40
|
+
* Returns true only if:
|
|
41
|
+
* - enableVoice is true
|
|
42
|
+
* - voiceConfigs has at least one enabled config
|
|
43
|
+
*/
|
|
44
|
+
export const checkVoIPEnabled = async (): Promise<{
|
|
45
|
+
enabled: boolean
|
|
46
|
+
hasValidToken: boolean
|
|
47
|
+
}> => {
|
|
48
|
+
try {
|
|
49
|
+
const { data } = await axios.get<NotificationConfigResponse>(
|
|
50
|
+
'/notifications/notification-configs',
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const config = data?.config
|
|
54
|
+
|
|
55
|
+
// Check if voice is enabled at tenant level
|
|
56
|
+
if (!config?.enableVoice) {
|
|
57
|
+
return { enabled: false, hasValidToken: false }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Check if there's at least one enabled voice config
|
|
61
|
+
const enabledVoiceConfig = config.voiceConfigs?.find(
|
|
62
|
+
(vc) => vc.isEnabled === true,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if (!enabledVoiceConfig) {
|
|
66
|
+
return { enabled: false, hasValidToken: false }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Check if token exists and is not expired
|
|
70
|
+
const hasValidToken = !!(
|
|
71
|
+
enabledVoiceConfig.EXOTEL?.token &&
|
|
72
|
+
enabledVoiceConfig.EXOTEL?.tokenExpiresAt &&
|
|
73
|
+
new Date(enabledVoiceConfig.EXOTEL.tokenExpiresAt) > new Date()
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return { enabled: true, hasValidToken }
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.error('Failed to check VoIP config:', error)
|
|
79
|
+
return { enabled: false, hasValidToken: false }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get the active Exotel voice token from the backend.
|
|
85
|
+
* The backend manages credentials securely and auto-refreshes tokens.
|
|
86
|
+
*/
|
|
87
|
+
export const getActiveVoiceToken = async (): Promise<{
|
|
88
|
+
token: string
|
|
89
|
+
expiresAt: string
|
|
90
|
+
} | null> => {
|
|
91
|
+
try {
|
|
92
|
+
const { data } = await axios.get(
|
|
93
|
+
'/notifications/notification-configs/voice-config/active-token',
|
|
94
|
+
)
|
|
95
|
+
return data
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error('Failed to fetch Exotel token:', error)
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @deprecated Use getActiveVoiceToken instead.
|
|
104
|
+
* This function is kept for backwards compatibility but will be removed.
|
|
105
|
+
*/
|
|
106
|
+
export const createExotelAppToken = async () => {
|
|
107
|
+
const tokenData = await getActiveVoiceToken()
|
|
108
|
+
if (!tokenData) {
|
|
109
|
+
throw new Error('No active voice configuration found')
|
|
110
|
+
}
|
|
111
|
+
return { Data: tokenData.token }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ============ Call Activity APIs ============
|
|
115
|
+
|
|
116
|
+
export interface CallActivity {
|
|
117
|
+
id: string
|
|
118
|
+
callSid: string
|
|
119
|
+
prospectId: string
|
|
120
|
+
direction: 'inbound' | 'outbound'
|
|
121
|
+
status: string
|
|
122
|
+
durationSeconds?: number
|
|
123
|
+
createdAt: string
|
|
124
|
+
startTime?: string
|
|
125
|
+
endTime?: string
|
|
126
|
+
fromNumber?: string
|
|
127
|
+
toNumber?: string
|
|
128
|
+
userId?: number
|
|
129
|
+
dispositionCategory?: string
|
|
130
|
+
dispositionReason?: string
|
|
131
|
+
dispositionNotes?: string
|
|
132
|
+
callStatusDisposition?: 'connected' | 'not_connected'
|
|
133
|
+
recordingUrl?: string
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface InitiateCallInput {
|
|
137
|
+
prospectId: string
|
|
138
|
+
toNumber: string
|
|
139
|
+
fromNumber?: string
|
|
140
|
+
userId?: number
|
|
141
|
+
callSid?: string // Exotel CallSid from SDK response
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface SaveDispositionInput {
|
|
145
|
+
callId: string
|
|
146
|
+
prospectId?: string
|
|
147
|
+
callStatusDisposition?: 'connected' | 'not_connected'
|
|
148
|
+
dispositionCategory: string
|
|
149
|
+
dispositionReason?: string
|
|
150
|
+
dispositionNotes?: string
|
|
151
|
+
callbackScheduledAt?: string
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface CancelCallInput {
|
|
155
|
+
callSid: string
|
|
156
|
+
reason?: string // 'declined' | 'cancelled' | 'missed' | 'timeout'
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface CallActivityResponse {
|
|
160
|
+
success: boolean
|
|
161
|
+
message: string
|
|
162
|
+
data: CallActivity
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface RegisterIncomingCallInput {
|
|
166
|
+
callSid: string
|
|
167
|
+
fromNumber: string
|
|
168
|
+
toNumber?: string
|
|
169
|
+
prospectId?: string
|
|
170
|
+
userId?: number
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface RegisterIncomingCallResponse {
|
|
174
|
+
success: boolean
|
|
175
|
+
message: string
|
|
176
|
+
data: CallActivity
|
|
177
|
+
prospect?: {
|
|
178
|
+
id: string
|
|
179
|
+
uniqueId?: number
|
|
180
|
+
prospectId?: string // Formatted ID like PRSP-000001
|
|
181
|
+
name: string
|
|
182
|
+
mobile: string
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Initiate an outbound call - creates call activity record
|
|
188
|
+
*/
|
|
189
|
+
export const initiateCallActivity = async (
|
|
190
|
+
input: InitiateCallInput,
|
|
191
|
+
): Promise<{ success: boolean; message: string; data: CallActivity }> => {
|
|
192
|
+
const endpoint =
|
|
193
|
+
workspace === 'common-workspace'
|
|
194
|
+
? `/paymentx/call-activities`
|
|
195
|
+
: `/call-activities`
|
|
196
|
+
|
|
197
|
+
const { data } = await axios.post(`${endpoint}/initiate`, input)
|
|
198
|
+
return data
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Register an incoming call - creates call activity record
|
|
203
|
+
*/
|
|
204
|
+
export const registerIncomingCall = async (
|
|
205
|
+
input: RegisterIncomingCallInput,
|
|
206
|
+
): Promise<RegisterIncomingCallResponse> => {
|
|
207
|
+
const endpoint =
|
|
208
|
+
workspace === 'common-workspace'
|
|
209
|
+
? `/paymentx/call-activities`
|
|
210
|
+
: `/call-activities`
|
|
211
|
+
|
|
212
|
+
const { data } = await axios.post(`${endpoint}/register-incoming`, input)
|
|
213
|
+
return data
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Fetch call history for a prospect
|
|
218
|
+
*/
|
|
219
|
+
export const fetchCallHistory = async (
|
|
220
|
+
prospectId: string,
|
|
221
|
+
): Promise<CallActivity[]> => {
|
|
222
|
+
const endpoint =
|
|
223
|
+
workspace === 'common-workspace'
|
|
224
|
+
? `/paymentx/call-activities`
|
|
225
|
+
: `/call-activities`
|
|
226
|
+
|
|
227
|
+
const { data } = await axios.get(`${endpoint}/by-prospect`, {
|
|
228
|
+
params: { prospectId },
|
|
229
|
+
})
|
|
230
|
+
return data
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Get call activity by ID
|
|
235
|
+
*/
|
|
236
|
+
export const getCallActivityById = async (
|
|
237
|
+
id: string,
|
|
238
|
+
): Promise<CallActivity> => {
|
|
239
|
+
const endpoint =
|
|
240
|
+
workspace === 'common-workspace'
|
|
241
|
+
? `/paymentx/call-activities`
|
|
242
|
+
: `/call-activities`
|
|
243
|
+
|
|
244
|
+
const { data } = await axios.get(`${endpoint}/${id}`)
|
|
245
|
+
return data
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Get call activity by CallSid
|
|
250
|
+
*/
|
|
251
|
+
export const getCallActivityByCallSid = async (
|
|
252
|
+
callSid: string,
|
|
253
|
+
): Promise<CallActivity> => {
|
|
254
|
+
const endpoint =
|
|
255
|
+
workspace === 'common-workspace'
|
|
256
|
+
? `/paymentx/call-activities`
|
|
257
|
+
: `/call-activities`
|
|
258
|
+
|
|
259
|
+
const { data } = await axios.get(`${endpoint}/by-call-sid`, {
|
|
260
|
+
params: { callSid },
|
|
261
|
+
})
|
|
262
|
+
return data
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Get call count for a prospect
|
|
267
|
+
*/
|
|
268
|
+
export const getCallCountByProspect = async (
|
|
269
|
+
prospectId: string,
|
|
270
|
+
): Promise<number> => {
|
|
271
|
+
const endpoint =
|
|
272
|
+
workspace === 'common-workspace'
|
|
273
|
+
? `/paymentx/call-activities`
|
|
274
|
+
: `/call-activities`
|
|
275
|
+
|
|
276
|
+
const { data } = await axios.get(`${endpoint}/count-by-prospect`, {
|
|
277
|
+
params: { prospectId },
|
|
278
|
+
})
|
|
279
|
+
return data?.count || 0
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Save call disposition
|
|
284
|
+
*/
|
|
285
|
+
export const saveCallDisposition = async (
|
|
286
|
+
input: SaveDispositionInput,
|
|
287
|
+
): Promise<CallActivity> => {
|
|
288
|
+
const endpoint =
|
|
289
|
+
workspace === 'common-workspace'
|
|
290
|
+
? `/paymentx/call-activities`
|
|
291
|
+
: `/call-activities`
|
|
292
|
+
|
|
293
|
+
const requestBody = {
|
|
294
|
+
callStatusDisposition: input.callStatusDisposition,
|
|
295
|
+
dispositionCategory: input.dispositionCategory,
|
|
296
|
+
dispositionNotes: input.dispositionNotes,
|
|
297
|
+
callbackScheduledAt: input.callbackScheduledAt,
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const { data } = await axios.post(`${endpoint}/${input.callId}/disposition`, requestBody)
|
|
301
|
+
return data
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Cancel/decline a call before it was connected
|
|
306
|
+
* Updates the call activity status to CANCELED
|
|
307
|
+
*/
|
|
308
|
+
export const cancelCall = async (
|
|
309
|
+
input: CancelCallInput,
|
|
310
|
+
): Promise<{ success: boolean; message: string }> => {
|
|
311
|
+
const endpoint =
|
|
312
|
+
workspace === 'common-workspace'
|
|
313
|
+
? `/paymentx/call-activities`
|
|
314
|
+
: `/call-activities`
|
|
315
|
+
|
|
316
|
+
const { data } = await axios.post(`${endpoint}/cancel`, input)
|
|
317
|
+
return data
|
|
318
|
+
}
|
|
319
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format duration in MM:SS format (e.g., "02:30")
|
|
3
|
+
*/
|
|
4
|
+
export const formatDurationTimer = (seconds: number): string => {
|
|
5
|
+
const mins = Math.floor(seconds / 60)
|
|
6
|
+
const secs = seconds % 60
|
|
7
|
+
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Format duration in verbose format (e.g., "2m 30s")
|
|
12
|
+
*/
|
|
13
|
+
export const formatDurationVerbose = (seconds: number): string => {
|
|
14
|
+
const mins = Math.floor(seconds / 60)
|
|
15
|
+
const secs = seconds % 60
|
|
16
|
+
return `${mins}m ${secs}s`
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './formatters'
|