diff --git a/.deployment b/.deployment new file mode 100644 index 00000000..62783318 --- /dev/null +++ b/.deployment @@ -0,0 +1,2 @@ +[config] +SCM_DO_BUILD_DURING_DEPLOYMENT=true \ No newline at end of file diff --git a/package.json b/package.json index 5911460e..2056ac9b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test": "set NODE_ENV=test&&jest --forceExit -i", "test-coverage": "set NODE_ENV=test&&jest --coverage --runInBand", "build-local": "node ./scripts/build", + "build-local-az": "node ./scripts/build-az", "build": "node ./scripts/serverless-build", "deploy": "node ./scripts/serverless-deploy", "build-test": "node ./scripts/serverless-build-test", diff --git a/scripts/build-az.js b/scripts/build-az.js new file mode 100644 index 00000000..7914d1cf --- /dev/null +++ b/scripts/build-az.js @@ -0,0 +1,28 @@ +const { rm, echo, cp, mkdir } = require('shelljs'); +const { resolve } = require('path'); + +const projectPath = resolve(__dirname, '..'); +const deployPath = resolve(projectPath, 'build') + +echo('clean path...'); +rm('-rf', `${deployPath}/*.js`); +rm('-rf', `${deployPath}/*.json`); +rm('-rf', `${deployPath}/models`); +rm('-rf', `${deployPath}/node_modules`); +rm('-rf', `${deployPath}/lib`); +rm('-rf', `${deployPath}/core`); +rm('-rf', `${deployPath}/adapters`); +echo('building...'); +mkdir(deployPath) +cp(`${projectPath}/package.json`, `${deployPath}/package.json`); +cp(`${projectPath}/package-lock.json`, `${deployPath}/package-lock.json`); +cp(`${projectPath}/src/index.js`, `${deployPath}/index.js`); +cp(`${projectPath}/src/server-az.js`, `${deployPath}/server.js`); +cp(`${projectPath}/src/dbAccessor.js`, `${deployPath}/dbAccessor.js`); +cp(`${projectPath}/src/releaseNotes.json`, `${deployPath}/releaseNotes.json`); +cp('-r', `${projectPath}/src/core`, `${deployPath}/core`); +cp('-r', `${projectPath}/src/lib`, `${deployPath}/lib`); +cp('-r', `${projectPath}/src/adapters`, `${deployPath}/adapters`); +cp('-r', `${projectPath}/src/models`, `${deployPath}/models`); + +echo(`build done, output in ${deployPath}`); diff --git a/src/adapters/freshdesk/index.js b/src/adapters/freshdesk/index.js new file mode 100644 index 00000000..d8c1078e --- /dev/null +++ b/src/adapters/freshdesk/index.js @@ -0,0 +1,737 @@ +const axios = require('axios'); +const moment = require('moment'); +const { parsePhoneNumber } = require('awesome-phonenumber'); +const { secondsToHoursMinutesSeconds } = require('../../lib/util'); +const { cat } = require('shelljs'); +const DEFAULT_RETRY_DELAY = 2000; + +function getApiUrl(fdDomain) { + return `https://${fdDomain}/api/v2`; +} + +function getAuthType() { + return 'apiKey'; // Return either 'oauth' OR 'apiKey' +} + +function getBasicAuth({ apiKey, hostname }) { + return Buffer.from(`${apiKey}:`).toString('base64'); +} + +async function getUserInfo({ authHeader, additionalInfo }) { + try { + + // API call to get logged in user info + let url = `${getApiUrl(additionalInfo.fdDomain)}/agents/me`; + const userInfoResponse = await axios.get(url, { + headers: { + 'Authorization': authHeader + } + }); + + if (!userInfoResponse.data || !userInfoResponse.data.contact) + throw new Error("Freshdesk API returned invalid response"); + + // toString() the id otherwise we get an error + const id = userInfoResponse.data.id.toString(); + const name = userInfoResponse.data.contact.name; + const timezoneName = ''; // Optional. Whether or not you want to log with regards to the user's timezone + const timezoneOffset = null; // Optional. Whether or not you want to log with regards to the user's timezone. It will need to be converted to a format that CRM platform uses, + + return { + successful: true, + platformUserInfo: { + id, + name, + timezoneName, + timezoneOffset, + platformAdditionalInfo: additionalInfo // this should save whatever extra info you want to save against the user + }, + returnMessage: { + messageType: 'success', + message: 'Successfully connected to Freshdesk.', + ttl: 3000 + } + }; + } + catch (e) { + return { + successful: false, + returnMessage: { + messageType: 'warning', + message: e.message || 'Failed to get user info.', + ttl: 3000 + } + } + } +} + +async function unAuthorize({ user }) { + await user.destroy(); + return { + returnMessage: { + messageType: 'success', + message: 'Successfully logged out from Freshdesk account.', + ttl: 3000 + } + } +} + +// - phoneNumber: phone number in E.164 format +// - overridingFormat: optional, if you want to override the phone number format +async function findContact({ user, authHeader, phoneNumber, overridingFormat, isExtension }) { + const matchedContactInfo = []; + phoneNumber = phoneNumber.trim(); + console.log(`[RC App] phone number: ${phoneNumber}`); + console.log(`[RC App] is extension number? ${isExtension}`); + + let searchResponse = null; + try { + searchResponse = await searchFDContact(user.platformAdditionalInfo.fdDomain, authHeader, phoneNumber); + } catch (error) { + return processErrorToRC(error); + } + + // add found contacts to matchedContactInfo or create and add a contact if needed + if (searchResponse && searchResponse.results.length > 0) { + const contacts = searchResponse.results; + for (var c of contacts) { + matchedContactInfo.push({ + id: c.id, + name: c.name, + type: "Contact", + phone: c.phone, + additionalInfo: null + }) + } + } else { + + let contactResponse = null + try { + contactResponse = await createFDContact(user.platformAdditionalInfo.fdDomain, authHeader, phoneNumber, `Unknown caller ${phoneNumber}`); + } catch (error) { + return processErrorToRC(error); + } + + if (contactResponse) { + matchedContactInfo.push({ + id: contactResponse.id, + name: contactResponse.name, + type: "Contact", + phone: contactResponse.phone, + additionalInfo: null + }) + } + } + + // If you want to support creating a new contact from the extension, below placeholder contact should be used + matchedContactInfo.push({ + id: 'createNewContact', + name: 'Create new contact...', + additionalInfo: null, + isNewContact: true + }); + + console.log('[RC App] findContact returning:', matchedContactInfo); + + return { + successful: true, + matchedContactInfo: matchedContactInfo, + returnMessage: { + messageType: 'success', + message: 'Successfully found contact.', + detaisl: [ + { + title: 'Details', + items: [ + { + id: '1', + type: 'text', + text: `Found ${matchedContactInfo.length} contacts` + } + ] + } + ], + ttl: 3000 + } + }; +} + +// - contactInfo: { id, type, phoneNumber, name } +// - callLog: same as in https://developers.ringcentral.com/api-reference/Call-Log/readUserCallRecord +// - note: note submitted by user +// - additionalSubmission: all additional fields that are setup in manifest under call log page +async function createCallLog({ user, contactInfo, authHeader, callLog, note, additionalSubmission, aiNote, transcript }) { + console.log('[RC App] createCallLog'); + // console.log('[RC App] createCallLog', contactInfo, callLog, note, additionalSubmission); + // console.log(`[RC App] adding call log... \n${JSON.stringify(callLog, null, 2)}`); + console.log(`[RC App] with note... \n${note}`); + // console.log(`[RC App] with additional info... \n${JSON.stringify(additionalSubmission, null, 2)}`); + + try { + console.debug('[RC App] START logActivity'); + await logActivity(user); + } catch (error) { + console.error('[RC App] logActivity failed'); + return processErrorToRC(error); + } + + let noteBody = 'RingCentral call details
'; + if (user.userSettings?.addCallLogContactNumber?.value ?? true) { noteBody = upsertContactPhoneNumber({ body: noteBody, phoneNumber: contactInfo.phoneNumber, direction: callLog.direction }); } + if (user.userSettings?.addCallLogDateTime?.value ?? true) { noteBody = upsertCallDateTime({ body: noteBody, startTime: callLog.startTime, timezoneOffset: user.timezoneOffset }); } + if (user.userSettings?.addCallLogDuration?.value ?? true) { noteBody = upsertCallDuration({ body: noteBody, duration: callLog.duration }); } + if (user.userSettings?.addCallLogResult?.value ?? true) { noteBody = upsertCallResult({ body: noteBody, result: callLog.result }); } + if (!!callLog.recording?.link && (user.userSettings?.addCallLogRecording?.value ?? true)) { noteBody = upsertCallRecording({ body: noteBody, recordingLink: callLog.recording.link }); } + noteBody += ''; + if (!!aiNote && (user.userSettings?.addCallLogAiNote?.value ?? true)) { noteBody = upsertAiNote({ body: noteBody, aiNote }); } + if (!!transcript && (user.userSettings?.addCallLogTranscript?.value ?? true)) { noteBody = upsertTranscript({ body: noteBody, transcript }); } + + // pass requester_id if contact was found, otherwise provide a phone value so FD will create a contact + let ticketBody = { + subject: callLog.customSubject ?? `[Call] ${callLog.direction} Call ${callLog.direction === 'Outbound' ? 'to' : 'from'} ${contactInfo.name} [${contactInfo.phone}]`, + description: (!note || note.trim().length === 0) ? "No note provided" : note, + requester_id: contactInfo.id ?? null, + status: 2, + priority: 1, + phone: contactInfo && contactInfo.id ? null : contactInfo.phoneNumber + }; + + // create ticket with the call log information + let ticketResponse = null + try { + ticketResponse = await createFDTicket(user.platformAdditionalInfo.fdDomain, authHeader, ticketBody); + } catch (error) { + return processErrorToRC(error); + } + + let recordId = ticketResponse.id; + + // create ticket note with the call log information + let noteResponse = null + try { + noteResponse = await createFDTicketNote(user.platformAdditionalInfo.fdDomain, authHeader, ticketResponse.id, noteBody, callLog.recording?.link); + } catch (error) { + return processErrorToRC(error); + } + + recordId += '-' + noteResponse.id; + + // the id we communicate is built up like this: - + return { + logId: recordId, + returnMessage: { + message: 'Call log added.', + messageType: 'success', + ttl: 3000 + } + }; +} + +async function getCallLog({ user, callLogId, authHeader }) { + console.log('[RC App] getCallLog'); + + let splitted = callLogId.split('-'); + let fdTicketId = null; + let fdNoteId = null; + if (splitted.length > 1) { + fdTicketId = splitted[0]; + fdNoteId = splitted[1]; + console.log('[RC App] updateCallLog got fd ids', fdTicketId, fdNoteId); + } + + let getLogRes = {}; + if (fdTicketId) { + let url = `${getApiUrl(user.platformAdditionalInfo.fdDomain)}/tickets/${callLogId}`; + + + let ticketResponse = null + try { + ticketResponse = await axios.get(url, { headers: { 'Authorization': authHeader } }); + } catch (error) { + return processErrorToRC(error); + } + getLogRes = { subject: ticketResponse.data.subject, note: ticketResponse.data.description_text }; + } + + return { + callLogInfo: { + subject: getLogRes.subject, + note: getLogRes.note + }, + returnMessage: { + message: 'Call log fetched.', + messageType: 'success', + ttl: 3000 + } + } +} + +// Will be called by RC when recordinglink is ready OR by user action when updating the call log manually +async function updateCallLog({ user, existingCallLog, authHeader, recordingLink, subject, note, startTime, duration, result, aiNote, transcript }) { + console.log('[RC App] updateCallLog', note ? 'hasnote' : 'no note'); + + let splitted = existingCallLog.thirdPartyLogId.split('-'); + let fdTicketId = null; + let fdNoteId = null; + if (splitted.length > 1) { + fdTicketId = splitted[0]; + fdNoteId = splitted[1]; + console.log('[RC App] updateCallLog got fd ids', fdTicketId, fdNoteId); + } + + let ticketNote = null + try { + ticketNote = await getTicketNoteById(user.platformAdditionalInfo.fdDomain, authHeader, fdTicketId, fdNoteId); + } catch (error) { + return processErrorToRC(error); + } + + + let logBody = ticketNote.body; + if (!!startTime && (user.userSettings?.addCallLogDateTime?.value ?? true)) { logBody = upsertCallDateTime({ body: logBody, startTime, timezoneOffset: user.timezoneOffset }); } + if (!!duration && (user.userSettings?.addCallLogDuration?.value ?? true)) { logBody = upsertCallDuration({ body: logBody, duration }); } + if (!!result && (user.userSettings?.addCallLogResult?.value ?? true)) { logBody = upsertCallResult({ body: logBody, result }); } + if (!!recordingLink && (user.userSettings?.addCallLogRecording?.value ?? true)) { logBody = upsertCallRecording({ body: logBody, recordingLink }); } + if (!!aiNote && (user.userSettings?.addCallLogAiNote?.value ?? true)) { logBody = upsertAiNote({ body: logBody, aiNote }); } + if (!!transcript && (user.userSettings?.addCallLogTranscript?.value ?? true)) { logBody = upsertTranscript({ body: logBody, transcript }); } + + // if note is given this is a user initiated update, update the ticket body only, otherwise add RC call info to ticket not contains call log info + if (note) { + try { + await updateFDTicket(user.platformAdditionalInfo.fdDomain, authHeader, fdTicketId, note); + } catch (error) { + return processErrorToRC(error); + } + } else { + try { + await updateFDTicketNote(user.platformAdditionalInfo.fdDomain, authHeader, fdNoteId, logBody); + } catch (error) { + return processErrorToRC(error); + } + } + + return { + updatedNote: note, + returnMessage: { + message: 'Call log updated.', + messageType: 'success', + ttl: 3000 + } + }; +} + +// Important: Is for SMS, Fax and Voicemail. SMS is only delivered once per 24 hours to prevent overloading the CRM API +// - contactInfo: { id, type, phoneNumber, name } +// - message : same as in https://developers.ringcentral.com/api-reference/Message-Store/readMessage +// - recordingLink: recording link of voice mail +// - additionalSubmission: all additional fields that are setup in manifest under call log page +async function createMessageLog({ user, contactInfo, authHeader, message, additionalSubmission, recordingLink, faxDocLink }) { + console.log('[RC App] createMessageLog'); + const messageType = !!recordingLink ? 'Voicemail' : (!!faxDocLink ? 'Fax' : 'SMS'); + let subject = ''; + let note = ''; + switch (messageType) { + case 'SMS': + subject = `SMS conversation with ${contactInfo.name} - ${moment(message.creationTime).utcOffset(user.timezoneOffset ?? 0).format('YYYY-MM-DD hh:mm:ss A')}`; + note = + `
${subject}
` + + 'Conversation summary

' + + `${moment(message.creationTime).utcOffset(user.timezoneOffset ?? 0).format('dddd, MMMM DD, YYYY')}
` + + 'Participants
' + + `` + + 'Conversation(1 messages)
' + + 'BEGIN
' + + '------------
' + + '' + + '------------
' + + 'END

'; + break; + case 'Fax': + subject = `Fax document sent from ${contactInfo.name} - ${moment(message.creationTime).utcOffset(user.timezoneOffset ?? 0).format('YYYY-MM-DD')}`; + note = `
${subject}
Fax document link: ${faxDocLink}`; + break; + case 'Voicemail': + subject = `Voicemail left by ${contactInfo.name} - ${moment(message.creationTime).utcOffset(user.timezoneOffset ?? 0).format('YYYY-MM-DD hh:mm:ss A')}`; + note = `
${subject}
Voicemail recording link: open`; + break; + } + + let ticketBody = { + subject: subject, + description: note, + requester_id: contactInfo.id ?? null, + status: 2, + priority: 1, + phone: contactInfo && contactInfo.id ? null : contactInfo.phoneNumber + }; + + console.log('[RC App] createMessageLog with payload', ticketBody); + + let ticketResponse = null + try { + ticketResponse = await createFDTicket(user.platformAdditionalInfo.fdDomain, authHeader, ticketBody); + } catch (error) { + return processErrorToRC(error); + } + + return { + logId: ticketResponse.id, + returnMessage: { + message: 'Message log added.', + messageType: 'success', + ttl: 3000 + } + }; +} + +// Used to update existing message log so to group message in the same day together +async function updateMessageLog({ user, contactInfo, existingMessageLog, message, authHeader }) { + console.log('[RC App] updateMessageLog'); + const existingLogId = existingMessageLog.thirdPartyLogId + + let url = `${getApiUrl(user.platformAdditionalInfo.fdDomain)}/tickets/${existingLogId}`; + + let ticketResponse = null + try { + ticketResponse = await axios.get(url, { headers: { 'Authorization': authHeader } }); + } catch (error) { + // RC considers messages part of the same conversation if its within a certain period, however, if the ticket does not exist anymore we need to re-create it + if (error.response && error.response.status === 404) + createMessageLog({ user, contactInfo, authHeader, message }); + else + return processErrorToRC(error); + } + + getLogRes = { subject: ticketResponse.data.subject, note: ticketResponse.data.description_text }; + + let logBody = ticketResponse.data.description; + const newMessageLog = + `
  • ${message.direction === 'Inbound' ? `${contactInfo.name} (${contactInfo.phoneNumber})` : message.from.name} ${moment(message.creationTime).utcOffset(Number(user.timezoneOffset ?? 0)).format('hh:mm A')}
    ` + + `${message.subject}
  • `; + logBody = logBody.replace('------------