@app-connect/core 0.0.1
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/README.md +266 -0
- package/adapter/mock.js +77 -0
- package/adapter/registry.js +115 -0
- package/handlers/admin.js +60 -0
- package/handlers/auth.js +156 -0
- package/handlers/contact.js +275 -0
- package/handlers/disposition.js +194 -0
- package/handlers/log.js +586 -0
- package/handlers/user.js +102 -0
- package/index.js +1202 -0
- package/lib/analytics.js +53 -0
- package/lib/callLogComposer.js +452 -0
- package/lib/encode.js +30 -0
- package/lib/generalErrorMessage.js +42 -0
- package/lib/jwt.js +16 -0
- package/lib/oauth.js +85 -0
- package/lib/util.js +40 -0
- package/models/adminConfigModel.js +17 -0
- package/models/cacheModel.js +23 -0
- package/models/callLogModel.js +27 -0
- package/models/dynamo/lockSchema.js +25 -0
- package/models/messageLogModel.js +25 -0
- package/models/sequelize.js +17 -0
- package/models/userModel.js +38 -0
- package/package.json +58 -0
- package/releaseNotes.json +578 -0
package/lib/analytics.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const Mixpanel = require('mixpanel');
|
|
2
|
+
const parser = require('ua-parser-js');
|
|
3
|
+
let packageJson = null;
|
|
4
|
+
try {
|
|
5
|
+
packageJson = require('../package.json');
|
|
6
|
+
}
|
|
7
|
+
catch (e) {
|
|
8
|
+
packageJson = require('../../package.json');
|
|
9
|
+
}
|
|
10
|
+
const appName = 'App Connect';
|
|
11
|
+
const defaultEventAddedVia = 'server';
|
|
12
|
+
const version = packageJson.version;
|
|
13
|
+
let mixpanel = null;
|
|
14
|
+
|
|
15
|
+
exports.init = function init() {
|
|
16
|
+
if (!process.env.MIXPANEL_TOKEN) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
mixpanel = Mixpanel.init(process.env.MIXPANEL_TOKEN);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
exports.track = function track({ eventName, interfaceName, adapterName, accountId, extensionId, success, requestDuration, userAgent, ip, author, eventAddedVia, extras = null }) {
|
|
23
|
+
if (!mixpanel || !extensionId) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const inUseEventAddedVia = eventAddedVia || defaultEventAddedVia;
|
|
27
|
+
mixpanel.people.set_once(extensionId, {
|
|
28
|
+
version,
|
|
29
|
+
appName,
|
|
30
|
+
crmPlatform: adapterName
|
|
31
|
+
});
|
|
32
|
+
const ua = parser(userAgent);
|
|
33
|
+
mixpanel.track(eventName, {
|
|
34
|
+
distinct_id: extensionId,
|
|
35
|
+
interfaceName,
|
|
36
|
+
adapterName,
|
|
37
|
+
rcAccountId: accountId,
|
|
38
|
+
extensionId,
|
|
39
|
+
success,
|
|
40
|
+
requestDuration,
|
|
41
|
+
collectedFrom: 'server',
|
|
42
|
+
version,
|
|
43
|
+
appName,
|
|
44
|
+
eventAddedVia: inUseEventAddedVia,
|
|
45
|
+
$browser: ua.browser.name,
|
|
46
|
+
$os: ua.os.name,
|
|
47
|
+
$device: ua.device.type,
|
|
48
|
+
ip,
|
|
49
|
+
author,
|
|
50
|
+
...extras
|
|
51
|
+
});
|
|
52
|
+
console.log(`Event: ${eventName}`);
|
|
53
|
+
}
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
const moment = require('moment-timezone');
|
|
2
|
+
const { secondsToHoursMinutesSeconds } = require('./util');
|
|
3
|
+
const adapterRegistry = require('../adapter/registry');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Centralized call log composition module
|
|
7
|
+
* Supports both plain text and HTML formats used across different CRM adapters
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Format types
|
|
11
|
+
const FORMAT_TYPES = {
|
|
12
|
+
PLAIN_TEXT: 'plainText',
|
|
13
|
+
HTML: 'html'
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Compose call log details based on user settings and format type
|
|
18
|
+
* @param {Object} params - Composition parameters
|
|
19
|
+
* @param {string} params.logFormat - logFormat type: 'plainText' or 'html'
|
|
20
|
+
* @param {string} params.existingBody - Existing log body (for updates)
|
|
21
|
+
* @param {Object} params.callLog - Call log information
|
|
22
|
+
* @param {Object} params.contactInfo - Contact information
|
|
23
|
+
* @param {Object} params.user - User information
|
|
24
|
+
* @param {string} params.note - User note
|
|
25
|
+
* @param {string} params.aiNote - AI generated note
|
|
26
|
+
* @param {string} params.transcript - Call transcript
|
|
27
|
+
* @param {string} params.recordingLink - Recording link
|
|
28
|
+
* @param {string} params.subject - Call subject
|
|
29
|
+
* @param {Date} params.startTime - Call start time
|
|
30
|
+
* @param {number} params.duration - Call duration in seconds
|
|
31
|
+
* @param {string} params.result - Call result
|
|
32
|
+
* @returns {Promise<string>} Composed log body
|
|
33
|
+
*/
|
|
34
|
+
async function composeCallLog(params) {
|
|
35
|
+
const {
|
|
36
|
+
logFormat = FORMAT_TYPES.PLAIN_TEXT,
|
|
37
|
+
existingBody = '',
|
|
38
|
+
callLog,
|
|
39
|
+
contactInfo,
|
|
40
|
+
user,
|
|
41
|
+
note,
|
|
42
|
+
aiNote,
|
|
43
|
+
transcript,
|
|
44
|
+
recordingLink,
|
|
45
|
+
subject,
|
|
46
|
+
startTime,
|
|
47
|
+
duration,
|
|
48
|
+
result,
|
|
49
|
+
platform
|
|
50
|
+
} = params;
|
|
51
|
+
|
|
52
|
+
let body = existingBody;
|
|
53
|
+
const userSettings = user.userSettings || {};
|
|
54
|
+
// Determine timezone handling
|
|
55
|
+
let resolvedStartTime = startTime || callLog?.startTime;
|
|
56
|
+
let timezoneOffset = user.timezoneOffset;
|
|
57
|
+
if (resolvedStartTime) {
|
|
58
|
+
resolvedStartTime = moment(resolvedStartTime);
|
|
59
|
+
}
|
|
60
|
+
// Apply upsert functions based on user settings
|
|
61
|
+
if (note && (userSettings?.addCallLogNote?.value ?? true)) {
|
|
62
|
+
body = upsertCallAgentNote({ body, note, logFormat });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (callLog?.sessionId && (userSettings?.addCallSessionId?.value ?? false)) {
|
|
66
|
+
body = upsertCallSessionId({ body, id: callLog.sessionId, logFormat });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (subject && (userSettings?.addCallLogSubject?.value ?? true)) {
|
|
70
|
+
body = upsertCallSubject({ body, subject, logFormat });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (contactInfo?.phoneNumber && (userSettings?.addCallLogContactNumber?.value ?? false)) {
|
|
74
|
+
body = upsertContactPhoneNumber({
|
|
75
|
+
body,
|
|
76
|
+
phoneNumber: contactInfo.phoneNumber,
|
|
77
|
+
direction: callLog?.direction,
|
|
78
|
+
logFormat
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (resolvedStartTime && (userSettings?.addCallLogDateTime?.value ?? true)) {
|
|
83
|
+
body = upsertCallDateTime({
|
|
84
|
+
body,
|
|
85
|
+
startTime: resolvedStartTime,
|
|
86
|
+
timezoneOffset,
|
|
87
|
+
logFormat
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (duration && (userSettings?.addCallLogDuration?.value ?? true)) {
|
|
92
|
+
body = upsertCallDuration({ body, duration, logFormat });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (result && (userSettings?.addCallLogResult?.value ?? true)) {
|
|
96
|
+
body = upsertCallResult({ body, result, logFormat });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (recordingLink && (userSettings?.addCallLogRecording?.value ?? true)) {
|
|
100
|
+
body = upsertCallRecording({ body, recordingLink, logFormat });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (aiNote && (userSettings?.addCallLogAINote?.value ?? true)) {
|
|
104
|
+
body = upsertAiNote({ body, aiNote, logFormat });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (transcript && (userSettings?.addCallLogTranscript?.value ?? true)) {
|
|
108
|
+
body = upsertTranscript({ body, transcript, logFormat });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return body;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Upsert functions for different log components
|
|
116
|
+
*/
|
|
117
|
+
|
|
118
|
+
function upsertCallAgentNote({ body, note, logFormat }) {
|
|
119
|
+
if (!note) return body;
|
|
120
|
+
if (logFormat === FORMAT_TYPES.HTML) {
|
|
121
|
+
// HTML logFormat with proper Agent notes section handling
|
|
122
|
+
const noteRegex = RegExp('<b>Agent notes</b>([\\s\\S]+?)Call details</b>');
|
|
123
|
+
if (noteRegex.test(body)) {
|
|
124
|
+
return body.replace(noteRegex, `<b>Agent notes</b><br>${note}<br><br><b>Call details</b>`);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
return `<b>Agent notes</b><br>${note}<br><br><b>Call details</b><br>` + body;
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
// Plain text logFormat - FIXED REGEX for multi-line notes with blank lines
|
|
131
|
+
const noteRegex = /- (?:Note|Agent notes): ([\s\S]*?)(?=\n- [A-Z][a-zA-Z\s/]*:|\n$|$)/;
|
|
132
|
+
if (noteRegex.test(body)) {
|
|
133
|
+
return body.replace(noteRegex, `- Note: ${note}`);
|
|
134
|
+
} else {
|
|
135
|
+
return `- Note: ${note}\n` + body;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function upsertCallSessionId({ body, id, logFormat }) {
|
|
141
|
+
if (!id) return body;
|
|
142
|
+
|
|
143
|
+
if (logFormat === FORMAT_TYPES.HTML) {
|
|
144
|
+
// More flexible regex that handles both <li> wrapped and unwrapped content
|
|
145
|
+
const idRegex = /(?:<li>)?<b>Session Id<\/b>:\s*([^<\n]+)(?:<\/li>|(?=<|$))/i;
|
|
146
|
+
if (idRegex.test(body)) {
|
|
147
|
+
return body.replace(idRegex, `<li><b>Session Id</b>: ${id}</li>`);
|
|
148
|
+
} else {
|
|
149
|
+
return body + `<li><b>Session Id</b>: ${id}</li>`;
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
// Match Session Id field and any trailing newlines, replace with single newline
|
|
153
|
+
const sessionIdRegex = /- Session Id: [^\n]*\n*/;
|
|
154
|
+
if (sessionIdRegex.test(body)) {
|
|
155
|
+
return body.replace(sessionIdRegex, `- Session Id: ${id}\n`);
|
|
156
|
+
} else {
|
|
157
|
+
return body + `- Session Id: ${id}\n`;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function upsertCallSubject({ body, subject, logFormat }) {
|
|
163
|
+
if (!subject) return body;
|
|
164
|
+
|
|
165
|
+
if (logFormat === FORMAT_TYPES.HTML) {
|
|
166
|
+
// More flexible regex that handles both <li> wrapped and unwrapped content
|
|
167
|
+
const subjectRegex = /(?:<li>)?<b>Summary<\/b>:\s*([^<\n]+)(?:<\/li>|(?=<|$))/i;
|
|
168
|
+
if (subjectRegex.test(body)) {
|
|
169
|
+
return body.replace(subjectRegex, `<li><b>Summary</b>: ${subject}</li>`);
|
|
170
|
+
} else {
|
|
171
|
+
return body + `<li><b>Summary</b>: ${subject}</li>`;
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
// Match Summary field and any trailing newlines, replace with single newline
|
|
175
|
+
const subjectRegex = /- Summary: [^\n]*\n*/;
|
|
176
|
+
if (subjectRegex.test(body)) {
|
|
177
|
+
return body.replace(subjectRegex, `- Summary: ${subject}\n`);
|
|
178
|
+
} else {
|
|
179
|
+
return body + `- Summary: ${subject}\n`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function upsertContactPhoneNumber({ body, phoneNumber, direction, logFormat }) {
|
|
185
|
+
if (!phoneNumber) return body;
|
|
186
|
+
|
|
187
|
+
const label = direction === 'Outbound' ? 'Recipient' : 'Caller';
|
|
188
|
+
let result = body;
|
|
189
|
+
|
|
190
|
+
if (logFormat === FORMAT_TYPES.HTML) {
|
|
191
|
+
// More flexible regex that handles both <li> wrapped and unwrapped content
|
|
192
|
+
const phoneNumberRegex = new RegExp(`(?:<li>)?<b>${label} phone number</b>:\\s*([^<\\n]+)(?:</li>|(?=<|$))`, 'i');
|
|
193
|
+
if (phoneNumberRegex.test(result)) {
|
|
194
|
+
result = result.replace(phoneNumberRegex, `<li><b>${label} phone number</b>: ${phoneNumber}</li>`);
|
|
195
|
+
} else {
|
|
196
|
+
result += `<li><b>${label} phone number</b>: ${phoneNumber}</li>`;
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
// More flexible regex that handles both with and without newlines
|
|
200
|
+
const phoneNumberRegex = /- Contact Number: ([^\n-]+)(?=\n-|\n|$)/;
|
|
201
|
+
if (phoneNumberRegex.test(result)) {
|
|
202
|
+
result = result.replace(phoneNumberRegex, `- Contact Number: ${phoneNumber}\n`);
|
|
203
|
+
} else {
|
|
204
|
+
result += `- Contact Number: ${phoneNumber}\n`;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function upsertCallDateTime({ body, startTime, timezoneOffset, logFormat }) {
|
|
211
|
+
if (!startTime) return body;
|
|
212
|
+
|
|
213
|
+
// Simple approach: convert to moment and apply timezone offset
|
|
214
|
+
let momentTime = moment(startTime);
|
|
215
|
+
if (timezoneOffset) {
|
|
216
|
+
// Handle both string offsets ('+05:30') and numeric offsets (330 minutes or 5.5 hours)
|
|
217
|
+
if (typeof timezoneOffset === 'string' && timezoneOffset.includes(':')) {
|
|
218
|
+
// String logFormat like '+05:30' or '-05:00'
|
|
219
|
+
momentTime = momentTime.utcOffset(timezoneOffset);
|
|
220
|
+
} else {
|
|
221
|
+
// Numeric logFormat (minutes or hours)
|
|
222
|
+
momentTime = momentTime.utcOffset(Number(timezoneOffset));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const formattedDateTime = momentTime.format('YYYY-MM-DD hh:mm:ss A');
|
|
226
|
+
let result = body;
|
|
227
|
+
|
|
228
|
+
if (logFormat === FORMAT_TYPES.HTML) {
|
|
229
|
+
// More flexible regex that handles both <li> wrapped and unwrapped content
|
|
230
|
+
const dateTimeRegex = /(?:<li>)?<b>Date\/time<\/b>:\s*([^<\n]+)(?:<\/li>|(?=<|$))/i;
|
|
231
|
+
if (dateTimeRegex.test(result)) {
|
|
232
|
+
result = result.replace(dateTimeRegex, `<li><b>Date/time</b>: ${formattedDateTime}</li>`);
|
|
233
|
+
} else {
|
|
234
|
+
result += `<li><b>Date/time</b>: ${formattedDateTime}</li>`;
|
|
235
|
+
}
|
|
236
|
+
} else {
|
|
237
|
+
// Handle duplicated Date/Time entries and match complete date/time values
|
|
238
|
+
const dateTimeRegex = /(?:- Date\/Time: [^-]*(?:-[^-]*)*)+/;
|
|
239
|
+
if (dateTimeRegex.test(result)) {
|
|
240
|
+
result = result.replace(dateTimeRegex, `- Date/Time: ${formattedDateTime}\n`);
|
|
241
|
+
} else {
|
|
242
|
+
result += `- Date/Time: ${formattedDateTime}\n`;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function upsertCallDuration({ body, duration, logFormat }) {
|
|
249
|
+
if (!duration) return body;
|
|
250
|
+
|
|
251
|
+
const formattedDuration = secondsToHoursMinutesSeconds(duration);
|
|
252
|
+
let result = body;
|
|
253
|
+
|
|
254
|
+
if (logFormat === FORMAT_TYPES.HTML) {
|
|
255
|
+
// More flexible regex that handles both <li> wrapped and unwrapped content
|
|
256
|
+
const durationRegex = /(?:<li>)?<b>Duration<\/b>:\s*([^<\n]+)(?:<\/li>|(?=<|$))/i;
|
|
257
|
+
if (durationRegex.test(result)) {
|
|
258
|
+
result = result.replace(durationRegex, `<li><b>Duration</b>: ${formattedDuration}</li>`);
|
|
259
|
+
} else {
|
|
260
|
+
result += `<li><b>Duration</b>: ${formattedDuration}</li>`;
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
// More flexible regex that handles both with and without newlines
|
|
264
|
+
const durationRegex = /- Duration: ([^\n-]+)(?=\n-|\n|$)/;
|
|
265
|
+
if (durationRegex.test(result)) {
|
|
266
|
+
result = result.replace(durationRegex, `- Duration: ${formattedDuration}\n`);
|
|
267
|
+
} else {
|
|
268
|
+
result += `- Duration: ${formattedDuration}\n`;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function upsertCallResult({ body, result, logFormat }) {
|
|
275
|
+
if (!result) return body;
|
|
276
|
+
|
|
277
|
+
let bodyResult = body;
|
|
278
|
+
|
|
279
|
+
if (logFormat === FORMAT_TYPES.HTML) {
|
|
280
|
+
// More flexible regex that handles both <li> wrapped and unwrapped content
|
|
281
|
+
const resultRegex = /(?:<li>)?<b>Result<\/b>:\s*([^<\n]+)(?:<\/li>|(?=<|$))/i;
|
|
282
|
+
if (resultRegex.test(bodyResult)) {
|
|
283
|
+
bodyResult = bodyResult.replace(resultRegex, `<li><b>Result</b>: ${result}</li>`);
|
|
284
|
+
} else {
|
|
285
|
+
bodyResult += `<li><b>Result</b>: ${result}</li>`;
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
// More flexible regex that handles both with and without newlines
|
|
289
|
+
const resultRegex = /- Result: ([^\n-]+)(?=\n-|\n|$)/;
|
|
290
|
+
if (resultRegex.test(bodyResult)) {
|
|
291
|
+
bodyResult = bodyResult.replace(resultRegex, `- Result: ${result}\n`);
|
|
292
|
+
} else {
|
|
293
|
+
bodyResult += `- Result: ${result}\n`;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return bodyResult;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function upsertCallRecording({ body, recordingLink, logFormat }) {
|
|
300
|
+
// console.log({ m: "upsertCallRecording", recordingLink, hasBody: !!body, logFormat, bodyLength: body?.length });
|
|
301
|
+
if (!recordingLink) return body;
|
|
302
|
+
|
|
303
|
+
let result = body;
|
|
304
|
+
|
|
305
|
+
if (logFormat === FORMAT_TYPES.HTML) {
|
|
306
|
+
// More flexible regex that handles both <li> wrapped and unwrapped content
|
|
307
|
+
const recordingLinkRegex = /(?:<li>)?<b>Call recording link<\/b>:\s*([^<\n]+)(?:<\/li>|(?=<|$))/i;
|
|
308
|
+
if (recordingLink) {
|
|
309
|
+
if (recordingLinkRegex.test(result)) {
|
|
310
|
+
if (recordingLink.startsWith('http')) {
|
|
311
|
+
result = result.replace(recordingLinkRegex, `<li><b>Call recording link</b>: <a target="_blank" href="${recordingLink}">open</a></li>`);
|
|
312
|
+
} else {
|
|
313
|
+
result = result.replace(recordingLinkRegex, `<li><b>Call recording link</b>: (pending...)</li>`);
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
let text = '';
|
|
317
|
+
if (recordingLink.startsWith('http')) {
|
|
318
|
+
text = `<li><b>Call recording link</b>: <a target="_blank" href="${recordingLink}">open</a></li>`;
|
|
319
|
+
} else {
|
|
320
|
+
text = '<li><b>Call recording link</b>: (pending...)</li>';
|
|
321
|
+
}
|
|
322
|
+
if (result.indexOf('</ul>') === -1) {
|
|
323
|
+
result += text;
|
|
324
|
+
} else {
|
|
325
|
+
result = result.replace('</ul>', `${text}</ul>`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
// Match recording link field and any trailing content, replace with single newline
|
|
331
|
+
const recordingLinkRegex = /- Call recording link: [^\n]*\n*/;
|
|
332
|
+
if (recordingLinkRegex.test(result)) {
|
|
333
|
+
result = result.replace(recordingLinkRegex, `- Call recording link: ${recordingLink}\n`);
|
|
334
|
+
} else {
|
|
335
|
+
if (result && !result.endsWith('\n')) {
|
|
336
|
+
result += '\n';
|
|
337
|
+
}
|
|
338
|
+
result += `- Call recording link: ${recordingLink}\n`;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return result;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function upsertAiNote({ body, aiNote, logFormat }) {
|
|
345
|
+
if (!aiNote) return body;
|
|
346
|
+
|
|
347
|
+
const clearedAiNote = aiNote.replace(/\n+$/, '');
|
|
348
|
+
let result = body;
|
|
349
|
+
|
|
350
|
+
if (logFormat === FORMAT_TYPES.HTML) {
|
|
351
|
+
const formattedAiNote = clearedAiNote.replace(/(?:\r\n|\r|\n)/g, '<br>');
|
|
352
|
+
const aiNoteRegex = /<div><b>AI Note<\/b><br>(.+?)<\/div>/;
|
|
353
|
+
if (aiNoteRegex.test(result)) {
|
|
354
|
+
result = result.replace(aiNoteRegex, `<div><b>AI Note</b><br>${formattedAiNote}</div>`);
|
|
355
|
+
} else {
|
|
356
|
+
result += `<div><b>AI Note</b><br>${formattedAiNote}</div><br>`;
|
|
357
|
+
}
|
|
358
|
+
} else {
|
|
359
|
+
const aiNoteRegex = /- AI Note:([\s\S]*?)--- END/;
|
|
360
|
+
if (aiNoteRegex.test(result)) {
|
|
361
|
+
result = result.replace(aiNoteRegex, `- AI Note:\n${clearedAiNote}\n--- END`);
|
|
362
|
+
} else {
|
|
363
|
+
result += `- AI Note:\n${clearedAiNote}\n--- END\n`;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return result;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function upsertTranscript({ body, transcript, logFormat }) {
|
|
370
|
+
if (!transcript) return body;
|
|
371
|
+
|
|
372
|
+
let result = body;
|
|
373
|
+
|
|
374
|
+
if (logFormat === FORMAT_TYPES.HTML) {
|
|
375
|
+
const formattedTranscript = transcript.replace(/(?:\r\n|\r|\n)/g, '<br>');
|
|
376
|
+
const transcriptRegex = /<div><b>Transcript<\/b><br>(.+?)<\/div>/;
|
|
377
|
+
if (transcriptRegex.test(result)) {
|
|
378
|
+
result = result.replace(transcriptRegex, `<div><b>Transcript</b><br>${formattedTranscript}</div>`);
|
|
379
|
+
} else {
|
|
380
|
+
result += `<div><b>Transcript</b><br>${formattedTranscript}</div><br>`;
|
|
381
|
+
}
|
|
382
|
+
} else {
|
|
383
|
+
const transcriptRegex = /- Transcript:([\s\S]*?)--- END/;
|
|
384
|
+
if (transcriptRegex.test(result)) {
|
|
385
|
+
result = result.replace(transcriptRegex, `- Transcript:\n${transcript}\n--- END`);
|
|
386
|
+
} else {
|
|
387
|
+
result += `- Transcript:\n${transcript}\n--- END\n`;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return result;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Helper function to determine format type for a CRM platform
|
|
395
|
+
* @param {string} platform - CRM platform name
|
|
396
|
+
* @returns {string} Format type
|
|
397
|
+
*/
|
|
398
|
+
function getLogFormatType(platform) {
|
|
399
|
+
const manifest = adapterRegistry.getManifest(platform, true);
|
|
400
|
+
const platformConfig = manifest.platforms?.[platform];
|
|
401
|
+
return platformConfig?.logFormat;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Create a specialized composition function for specific CRM requirements
|
|
406
|
+
* @param {string} platform - CRM platform name
|
|
407
|
+
* @returns {Function} Customized composition function
|
|
408
|
+
*/
|
|
409
|
+
function createComposer(platform) {
|
|
410
|
+
const logFormat = getLogFormatType(platform);
|
|
411
|
+
|
|
412
|
+
return async function (params) {
|
|
413
|
+
// Add platform-specific formatting
|
|
414
|
+
if (logFormat === FORMAT_TYPES.HTML && platform === 'pipedrive') {
|
|
415
|
+
// Pipedrive wraps call details in <ul> tags
|
|
416
|
+
const composed = await composeCallLog({ ...params, logFormat });
|
|
417
|
+
if (composed && !composed.includes('<ul>')) {
|
|
418
|
+
return `<b>Call details</b><ul>${composed}</ul>`;
|
|
419
|
+
}
|
|
420
|
+
return composed;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (logFormat === FORMAT_TYPES.HTML && platform === 'bullhorn') {
|
|
424
|
+
// Bullhorn also wraps call details in <ul> tags
|
|
425
|
+
const composed = await composeCallLog({ ...params, logFormat });
|
|
426
|
+
if (composed && !composed.includes('<ul>')) {
|
|
427
|
+
return `<b>Call details</b><ul>${composed}</ul>`;
|
|
428
|
+
}
|
|
429
|
+
return composed;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return composeCallLog({ ...params, logFormat });
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
module.exports = {
|
|
437
|
+
composeCallLog,
|
|
438
|
+
createComposer,
|
|
439
|
+
getLogFormatType,
|
|
440
|
+
FORMAT_TYPES,
|
|
441
|
+
// Export individual upsert functions for backward compatibility
|
|
442
|
+
upsertCallAgentNote,
|
|
443
|
+
upsertCallSessionId,
|
|
444
|
+
upsertCallSubject,
|
|
445
|
+
upsertContactPhoneNumber,
|
|
446
|
+
upsertCallDateTime,
|
|
447
|
+
upsertCallDuration,
|
|
448
|
+
upsertCallResult,
|
|
449
|
+
upsertCallRecording,
|
|
450
|
+
upsertAiNote,
|
|
451
|
+
upsertTranscript
|
|
452
|
+
};
|
package/lib/encode.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
function getCipherKey() {
|
|
4
|
+
if (!process.env.APP_SERVER_SECRET_KEY) {
|
|
5
|
+
throw new Error('APP_SERVER_SECRET_KEY is not defined');
|
|
6
|
+
}
|
|
7
|
+
if (process.env.APP_SERVER_SECRET_KEY.length < 32) {
|
|
8
|
+
// pad secret key with spaces if it is less than 32 bytes
|
|
9
|
+
return process.env.APP_SERVER_SECRET_KEY.padEnd(32, ' ');
|
|
10
|
+
}
|
|
11
|
+
if (process.env.APP_SERVER_SECRET_KEY.length > 32) {
|
|
12
|
+
// truncate secret key if it is more than 32 bytes
|
|
13
|
+
return process.env.APP_SERVER_SECRET_KEY.slice(0, 32);
|
|
14
|
+
}
|
|
15
|
+
return process.env.APP_SERVER_SECRET_KEY;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function encode(data) {
|
|
19
|
+
const cipher = crypto.createCipheriv('aes-256-cbc', getCipherKey(), Buffer.alloc(16, 0));
|
|
20
|
+
return cipher.update(data, 'utf8', 'hex') + cipher.final('hex');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function decoded(encryptedData) {
|
|
24
|
+
const decipher = crypto.createDecipheriv('aes-256-cbc', getCipherKey(), Buffer.alloc(16, 0));
|
|
25
|
+
return decipher.update(encryptedData, 'hex', 'utf8') + decipher.final('utf8');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
exports.encode = encode;
|
|
30
|
+
exports.decoded = decoded;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
function rateLimitErrorMessage({ platform }) {
|
|
2
|
+
return {
|
|
3
|
+
message: `Rate limit exceeded`,
|
|
4
|
+
messageType: 'warning',
|
|
5
|
+
details: [
|
|
6
|
+
{
|
|
7
|
+
title: 'Details',
|
|
8
|
+
items: [
|
|
9
|
+
{
|
|
10
|
+
id: '1',
|
|
11
|
+
type: 'text',
|
|
12
|
+
text: `You have exceeded the maximum number of requests allowed by ${platform}. Please try again in the next minute. If the problem persists please contact support.`
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
],
|
|
17
|
+
ttl: 5000
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function authorizationErrorMessage({ platform }) {
|
|
22
|
+
return {
|
|
23
|
+
message: `Authorization error`,
|
|
24
|
+
messageType: 'warning',
|
|
25
|
+
details: [
|
|
26
|
+
{
|
|
27
|
+
title: 'Details',
|
|
28
|
+
items: [
|
|
29
|
+
{
|
|
30
|
+
id: '1',
|
|
31
|
+
type: 'text',
|
|
32
|
+
text: `It seems like there's something wrong with your authorization of ${platform}. Please Logout and then Connect your ${platform} account within this extension.`
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
ttl: 5000
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
exports.rateLimitErrorMessage = rateLimitErrorMessage;
|
|
42
|
+
exports.authorizationErrorMessage = authorizationErrorMessage;
|
package/lib/jwt.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const { sign, verify } = require('jsonwebtoken');
|
|
2
|
+
|
|
3
|
+
function generateJwt(data) {
|
|
4
|
+
return sign(data, process.env.APP_SERVER_SECRET_KEY, { expiresIn: '120y' })
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function decodeJwt(token) {
|
|
8
|
+
try {
|
|
9
|
+
return verify(token, process.env.APP_SERVER_SECRET_KEY);
|
|
10
|
+
} catch (e) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
exports.generateJwt = generateJwt;
|
|
16
|
+
exports.decodeJwt = decodeJwt;
|
package/lib/oauth.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/* eslint-disable no-param-reassign */
|
|
2
|
+
const ClientOAuth2 = require('client-oauth2');
|
|
3
|
+
const { Lock } = require('../models/dynamo/lockSchema');
|
|
4
|
+
const { UserModel } = require('../models/userModel');
|
|
5
|
+
const adapterRegistry = require('../adapter/registry');
|
|
6
|
+
|
|
7
|
+
// oauthApp strategy is default to 'code' which use credentials to get accessCode, then exchange for accessToken and refreshToken.
|
|
8
|
+
// To change to other strategies, please refer to: https://github.com/mulesoft-labs/js-client-oauth2
|
|
9
|
+
function getOAuthApp({ clientId, clientSecret, accessTokenUri, authorizationUri, redirectUri, scopes }) {
|
|
10
|
+
return new ClientOAuth2({
|
|
11
|
+
clientId: clientId,
|
|
12
|
+
clientSecret: clientSecret,
|
|
13
|
+
accessTokenUri: accessTokenUri,
|
|
14
|
+
authorizationUri: authorizationUri,
|
|
15
|
+
redirectUri: redirectUri,
|
|
16
|
+
scopes: scopes
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async function checkAndRefreshAccessToken(oauthApp, user, tokenLockTimeout = 10) {
|
|
22
|
+
const dateNow = new Date();
|
|
23
|
+
const tokenExpiry = new Date(user.tokenExpiry);
|
|
24
|
+
const expiryBuffer = 1000 * 60 * 2; // 2 minutes => 120000ms
|
|
25
|
+
// Special case: Bullhorn
|
|
26
|
+
if (user.platform) {
|
|
27
|
+
const platformModule = adapterRegistry.getAdapter(user.platform);
|
|
28
|
+
if (platformModule.checkAndRefreshAccessToken) {
|
|
29
|
+
return platformModule.checkAndRefreshAccessToken(oauthApp, user, tokenLockTimeout);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// Other CRMs
|
|
33
|
+
if (user && user.accessToken && user.refreshToken && tokenExpiry.getTime() < (dateNow.getTime() + expiryBuffer)) {
|
|
34
|
+
// case: use dynamoDB to manage token refresh lock
|
|
35
|
+
if (process.env.USE_TOKEN_REFRESH_LOCK === 'true') {
|
|
36
|
+
let lock = await Lock.get({ userId: user.id });
|
|
37
|
+
let newLock;
|
|
38
|
+
if (!!lock?.ttl && lock.ttl < dateNow.getTime()) {
|
|
39
|
+
await lock.delete();
|
|
40
|
+
lock = null;
|
|
41
|
+
}
|
|
42
|
+
if (lock) {
|
|
43
|
+
let processTime = 0;
|
|
44
|
+
while (!!lock && processTime < tokenLockTimeout) {
|
|
45
|
+
await new Promise(resolve => setTimeout(resolve, 2000)); // wait for 2 seconds
|
|
46
|
+
processTime += 2;
|
|
47
|
+
lock = await Lock.get({ userId: user.id });
|
|
48
|
+
}
|
|
49
|
+
// Timeout -> let users try another time
|
|
50
|
+
if (processTime >= tokenLockTimeout) {
|
|
51
|
+
throw new Error('Token lock timeout');
|
|
52
|
+
}
|
|
53
|
+
user = await UserModel.findByPk(user.id);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
newLock = await Lock.create({
|
|
57
|
+
userId: user.id
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const token = oauthApp.createToken(user.accessToken, user.refreshToken);
|
|
61
|
+
const { accessToken, refreshToken, expires } = await token.refresh();
|
|
62
|
+
user.accessToken = accessToken;
|
|
63
|
+
user.refreshToken = refreshToken;
|
|
64
|
+
user.tokenExpiry = expires;
|
|
65
|
+
await user.save();
|
|
66
|
+
if (newLock) {
|
|
67
|
+
await newLock.delete();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// case: run withou token refresh lock
|
|
71
|
+
else {
|
|
72
|
+
const token = oauthApp.createToken(user.accessToken, user.refreshToken);
|
|
73
|
+
const { accessToken, refreshToken, expires } = await token.refresh();
|
|
74
|
+
user.accessToken = accessToken;
|
|
75
|
+
user.refreshToken = refreshToken;
|
|
76
|
+
user.tokenExpiry = expires;
|
|
77
|
+
await user.save();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
}
|
|
81
|
+
return user;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
exports.checkAndRefreshAccessToken = checkAndRefreshAccessToken;
|
|
85
|
+
exports.getOAuthApp = getOAuthApp;
|