const {CallDirection, CallStatus} = require('../utils/constants'); const parseUri = require('drachtio-srf').parseUri; const crypto = require('crypto'); const {JAMBONES_API_BASE_URL} = require('../config'); /** * Fields that belong on the status webhook but must never enter the redis call record. * * That record is written with hmset, which MERGES, so a field able to go from set back to * unset - as sipReasonHeader does, unlike sipStatus/sipReason which are only ever * overwritten - would strand a value from an earlier status change where GET /Calls/:sid * reports it: a leg that saw "183 + Reason: Q.850;cause=31" then answered cleanly and ended * on a plain BYE would report a temporary-failure cause for a call that completed normally. * * Excluding it removes that problem instead of managing it. Redis holds calls that are * still live, where there is usually no interesting cause yet; by the time there is one the * call is over and the history belongs in the CDR. Add any future webhook-only field here. */ const WEBHOOK_ONLY_FIELDS = ['sipReasonHeader']; /** * @classdesc Represents the common information for all calls * that is provided in call status webhooks */ class CallInfo { constructor(opts) { let from ; let srf; this.direction = opts.direction; this.traceId = opts.traceId; this.hasRecording = false; this.callTerminationBy = undefined; if (opts.req) { const u = opts.req.getParsedHeader('from'); const uri = parseUri(u.uri); from = uri.user; this.callerName = u.name || ''; } if (this.direction === CallDirection.Inbound) { // inbound call const {app, req} = opts; srf = req.srf; this.callSid = req.locals.callSid, this.accountSid = app.account_sid, this.applicationSid = app.application_sid; this.from = from || req.callingNumber; this.to = req.calledNumber; this.callId = req.get('Call-ID'); this.sipStatus = 100; this.sipReason = 'Trying'; this.callStatus = CallStatus.Trying; this.sbcCallid = req.get('X-CID'); this.originatingSipIp = req.get('X-Forwarded-For'); this.originatingSipTrunkName = req.get('X-Originating-Carrier'); const {siprec} = req.locals; if (siprec) { const caller = parseUri(req.locals.callingNumber); const callee = parseUri(req.locals.calledNumber); this.participants = [ { participant: 'caller', uriUser: caller?.user, uriHost: caller?.host }, { participant: 'callee', uriUser: callee?.user, uriHost: callee?.host } ]; } } else if (opts.parentCallInfo) { // outbound call that is a child of an existing call const {req, parentCallInfo, to, callSid} = opts; srf = req.srf; this.callSid = callSid || crypto.randomUUID(); this.parentCallSid = parentCallInfo.callSid; this.accountSid = parentCallInfo.accountSid; this.applicationSid = parentCallInfo.applicationSid; this.from = from || req.callingNumber; this.to = to; this.callerId = this.from.name || req.callingNumber; this.callId = req.get('Call-ID'); this.callStatus = CallStatus.Trying, this.sipStatus = 100; this.sipReason = 'Trying'; } else if (this.direction === CallDirection.None) { // outbound SMS const {messageSid, accountSid, applicationSid, res} = opts; srf = res.srf; this.messageSid = messageSid; this.accountSid = accountSid; this.applicationSid = applicationSid; this.res = res; } else { // outbound call triggered by REST const {req, callSid, accountSid, applicationSid, to, tag} = opts; srf = req.srf; this.callSid = callSid; this.accountSid = accountSid; this.applicationSid = applicationSid; this.callStatus = CallStatus.Trying, this.callId = req.get('Call-ID'); this.sipStatus = 100; this.sipReason = 'Trying'; this.from = from || req.callingNumber; this.to = to; if (tag) this._customerData = tag; } this.localSipAddress = srf.locals.localSipAddress; if (srf.locals.publicIp) { this.publicIp = srf.locals.publicIp; } } /** * update the status of the call * @param {string} callStatus - current call status * @param {number} sipStatus - current sip status * @param {string} [sipReason] - reason phrase from the SIP status line * @param {string} [sipReasonHeader] - RFC 3326 Reason header of the SIP message that caused * this status change, if it carried one. Unlike the fields above this is assigned * unconditionally, so that it always describes the current change rather than lingering * from an earlier one. * * This is a webhook-only field, deliberately kept OUT of the redis call record: that * record is written with hmset, which MERGES, so a field that can legitimately go from * set back to unset - as this one does, unlike sipStatus/sipReason - would strand a cause * from an earlier status change where GET /Calls/:sid reports it. Keeping it undefined * when absent means realtimedb-helpers filters it out and it is never written at all, * which removes the problem rather than managing it. Redis holds calls that are still * live, where there is usually no interesting cause yet; by the time there is one the * call is over and the history belongs in the CDR. */ updateCallStatus(callStatus, sipStatus, sipReason, sipReasonHeader) { this.callStatus = callStatus; if (sipStatus) this.sipStatus = sipStatus; if (sipReason) this.sipReason = sipReason; this.sipReasonHeader = sipReasonHeader; } /** * associate customer-provided data with the call information. * this information will be provided with every call status callhook */ set customerData(obj) { this._customerData = obj; } get customerData() { return this._customerData; } set sipHeaders(obj) { this._sipHeaders = obj; } get sipHeaders() { return this._sipHeaders; } /** * Project a call-status shape into what may be written to the redis call record. * Both writers go through this, from different bases: CallSession writes the webhook * payload, SingleDialer writes the CallInfo instance itself. */ static toRedisRecord(obj) { const record = Object.assign({}, obj); WEBHOOK_ONLY_FIELDS.forEach((f) => delete record[f]); return record; } toJSON() { const obj = { callSid: this.callSid, direction: this.direction, from: this.from, to: this.to, callId: this.callId, sbcCallid: this.sbcCallid, sipStatus: this.sipStatus, sipReason: this.sipReason, callStatus: this.callStatus, callerId: this.callerId, accountSid: this.accountSid, traceId: this.traceId, applicationSid: this.applicationSid, fsSipAddress: this.localSipAddress }; ['parentCallSid', 'originatingSipIp', 'originatingSipTrunkName', 'callTerminationBy', 'sipReasonHeader'].forEach((prop) => { if (this[prop]) obj[prop] = this[prop]; }); if (typeof this.duration === 'number') obj.duration = this.duration; if (this._customerData) { Object.assign(obj, {customerData: this._customerData}); } if (this._sipHeaders) { Object.assign(obj, {headers: this._sipHeaders}); } if (JAMBONES_API_BASE_URL) { Object.assign(obj, {apiBaseUrl: JAMBONES_API_BASE_URL}); } if (this.publicIp) { Object.assign(obj, {fsPublicIp: this.publicIp}); } return obj; } } module.exports = CallInfo;