diff --git a/app.js b/app.js index 4726b2d6..747f7319 100644 --- a/app.js +++ b/app.js @@ -4,7 +4,10 @@ const Mrf = require('drachtio-fsmrf'); srf.locals.mrf = new Mrf(srf); const config = require('config'); const PORT = process.env.HTTP_PORT || config.get('defaultHttpPort'); -const logger = srf.locals.parentLogger = require('pino')(config.get('logging')); +const opts = Object.assign({ + timestamp: () => {return `, "time": "${new Date().toISOString()}"`;} +}, config.get('logging')); +const logger = srf.locals.parentLogger = require('pino')(opts); const installSrfLocals = require('./lib/utils/install-srf-locals'); installSrfLocals(srf, logger); diff --git a/config/default.json.example b/config/default.json.example index c112eaa8..a8efc5be 100644 --- a/config/default.json.example +++ b/config/default.json.example @@ -21,6 +21,7 @@ "host": "127.0.0.1", "port": 6379 }, + "defaultHttpPort": 3000, "outdials": { "drachtio": [ { diff --git a/lib/http-routes/api/update-call.js b/lib/http-routes/api/update-call.js index bc1e9240..ffd42caf 100644 --- a/lib/http-routes/api/update-call.js +++ b/lib/http-routes/api/update-call.js @@ -1,13 +1,42 @@ const router = require('express').Router(); const sysError = require('./error'); const sessionTracker = require('../../session/session-tracker'); +const {DbErrorBadRequest, DbErrorUnprocessableRequest} = require('../utils/errors'); +const {CallStatus, CallDirection} = require('../../utils/constants'); +/** + * validate the payload and retrieve the CallSession for the CallSid + */ +function retrieveCallSession(callSid, opts) { + if (opts.call_status_hook && !opts.call_hook) { + throw new DbErrorBadRequest('call_status_hook can be updated only when call_hook is also being updated'); + } + const cs = sessionTracker.get(callSid); + + if (opts.call_status === CallStatus.Completed && !cs.hasStableDialog) { + throw new DbErrorUnprocessableRequest('current call state is incompatible with requested action'); + } + else if (opts.call_status === CallStatus.NoAnswer) { + if (cs.direction === CallDirection.Outbound) { + if (!cs.isOutboundCallRinging) { + throw new DbErrorUnprocessableRequest('current call state is incompatible with requested action'); + } + } + else { + if (cs.isInboundCallAnswered) { + throw new DbErrorUnprocessableRequest('current call state is incompatible with requested action'); + } + } + } + + return cs; +} router.post('/:callSid', async(req, res) => { const logger = req.app.locals.logger; const callSid = req.params.callSid; logger.debug({body: req.body}, 'got upateCall request'); try { - const cs = sessionTracker.get(callSid); + const cs = retrieveCallSession(callSid, req.body); if (!cs) { logger.info(`updateCall: callSid not found ${callSid}`); return res.sendStatus(404); diff --git a/lib/middleware.js b/lib/middleware.js index 647efb17..02614c3e 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -6,7 +6,7 @@ const retrieveApp = require('./utils/retrieve-app'); const parseUrl = require('parse-url'); module.exports = function(srf, logger) { - const {lookupAppByPhoneNumber} = srf.locals.dbHelpers; + const {lookupAppByPhoneNumber, lookupApplicationBySid} = srf.locals.dbHelpers; function initLocals(req, res, next) { const callSid = uuidv4(); @@ -14,6 +14,11 @@ module.exports = function(srf, logger) { callSid, logger: logger.child({callId: req.get('Call-ID'), callSid}) }; + if (req.has('X-Application-Sid')) { + const application_sid = req.get('X-Application-Sid'); + req.locals.logger.debug(`got application from X-Application-Sid header: ${application_sid}`); + req.locals.application_sid = application_sid; + } next(); } @@ -44,7 +49,13 @@ module.exports = function(srf, logger) { async function retrieveApplication(req, res, next) { const logger = req.locals.logger; try { - const app = await lookupAppByPhoneNumber(req.locals.calledNumber); + let app; + if (req.locals.application_sid) { + app = await lookupApplicationBySid(req.locals.application_sid); + } + else { + app = await lookupAppByPhoneNumber(req.locals.calledNumber); + } if (!app || !app.call_hook || !app.call_hook.url) { logger.info(`rejecting call to ${req.locals.calledNumber}: no application or webhook url`); return res.send(480, { diff --git a/lib/session/call-session.js b/lib/session/call-session.js index d5c36e45..22dfb7b1 100644 --- a/lib/session/call-session.js +++ b/lib/session/call-session.js @@ -1,6 +1,6 @@ const Emitter = require('events'); const config = require('config'); -const {CallDirection, TaskPreconditions, CallStatus} = require('../utils/constants'); +const {CallDirection, TaskPreconditions, CallStatus, TaskName} = require('../utils/constants'); const hooks = require('../utils/notifiers'); const moment = require('moment'); const assert = require('assert'); @@ -63,6 +63,18 @@ class CallSession extends Emitter { return this.application.speech_recognizer_language; } + get hasStableDialog() { + return this.dlg && this.dlg.connected; + } + + get isOutboundCallRinging() { + return this.direction === CallDirection.Outbound && this.req && !this.dlg; + } + + get isInboundCallAnswered() { + return this.direction === CallDirection.Inbound && this.res.finalResponseSent; + } + async exec() { this.logger.info(`CallSession:exec starting task list with ${this.tasks.length} tasks`); while (this.tasks.length && !this.callGone) { @@ -128,18 +140,45 @@ class CallSession extends Emitter { async updateCall(opts) { this.logger.debug(opts, 'CallSession:updateCall'); - if (opts.call_status === 'completed' && this.dlg) { + + if (opts.call_status === CallStatus.Completed && this.dlg) { this.logger.info('CallSession:updateCall hanging up call due to request from api'); this._callerHungup(); } + else if (opts.call_status === CallStatus.NoAnswer) { + if (this.direction === CallDirection.Inbound) { + if (this.res && !this.res.finalResponseSent) { + this.res.send(503); + this._callReleased(); + } + } + else { + if (this.req && !this.dlg) { + this.req.cancel(); + this._callReleased(); + } + } + } else if (opts.call_hook && opts.call_hook.url) { const hook = this.normalizeUrl(opts.call_hook.url, opts.call_hook.method, opts.call_hook.auth); this.logger.info({hook}, 'CallSession:updateCall replacing application due to request from api'); const {actionHook} = hooks(this.logger, this.callInfo); + if (opts.call_status_hook) this.call_status_hook = opts.call_status_hook; const tasks = await actionHook(hook); this.logger.info({tasks}, 'CallSession:updateCall new task list'); this.replaceApplication(tasks); } + else if (opts.listen_status) { + const task = this.currentTask; + if (!task || ![TaskName.Dial, TaskName.Listen].includes(task.name)) { + return this.logger.info(`CallSession:updateCall - disregarding listen_status in task ${task.name}`); + } + const listenTask = task.name === TaskName.Listen ? task : task.listenTask; + if (!listenTask) { + return this.logger.info('CallSession:updateCall - disregarding listen_status as Dial does not have a listen'); + } + listenTask.updateListen(opts.listen_status); + } } /** @@ -262,6 +301,7 @@ class CallSession extends Emitter { } return {ms: this.ms, ep: this.ep}; } + _notifyCallStatusChange({callStatus, sipStatus, duration}) { assert((typeof duration === 'number' && callStatus === CallStatus.Completed) || (!duration && callStatus !== CallStatus.Completed), diff --git a/lib/session/inbound-call-session.js b/lib/session/inbound-call-session.js index 3db153f8..f8f2e7fc 100644 --- a/lib/session/inbound-call-session.js +++ b/lib/session/inbound-call-session.js @@ -26,7 +26,7 @@ class InboundCallSession extends CallSession { this.logger.info('InboundCallSession:_onTasksDone auto-generating non-success response to invite'); this.res.send(603); } - else if (this.dlg.connected) { + else if (this.dlg && this.dlg.connected) { assert(this.dlg.connectTime); const duration = moment().diff(this.dlg.connectTime, 'seconds'); this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration}); diff --git a/lib/tasks/listen.js b/lib/tasks/listen.js index ae221e08..d36342a8 100644 --- a/lib/tasks/listen.js +++ b/lib/tasks/listen.js @@ -1,5 +1,5 @@ const Task = require('./task'); -const {TaskName, TaskPreconditions, ListenEvents} = require('../utils/constants'); +const {TaskName, TaskPreconditions, ListenEvents, ListenStatus} = require('../utils/constants'); const makeTask = require('./make_task'); const moment = require('moment'); @@ -68,6 +68,23 @@ class TaskListen extends Task { this.notifyTaskDone(); } + updateListen(status) { + if (!this.killed && this.ep && this.ep.connected) { + this.logger.info(`TaskListen:updateListen status ${status}`); + switch (status) { + case ListenStatus.Pause: + this.ep.forkAudioPause().catch((err) => this.logger.info(err, 'TaskListen: error pausing audio')); + break; + case ListenStatus.Silence: + this.ep.forkAudioPause().catch((err) => this.logger.info(err, 'TaskListen: error pausing audio')); + break; + case ListenStatus.Resume: + this.ep.forkAudioResume().catch((err) => this.logger.info(err, 'TaskListen: error resuming audio')); + break; + } + } + } + async _playBeep(ep) { await ep.play('tone_stream://L=1;%(500, 0, 1500)') .catch((err) => this.logger.info(err, 'TaskListen:_playBeep Error playing beep')); diff --git a/lib/tasks/say.js b/lib/tasks/say.js index af748a44..b8a44de6 100644 --- a/lib/tasks/say.js +++ b/lib/tasks/say.js @@ -7,6 +7,7 @@ class TaskSay extends Task { this.preconditions = TaskPreconditions.Endpoint; this.text = this.data.text; + this.loop = this.data.loop || 1; this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia); if (this.data.synthesizer) { this.voice = this.data.synthesizer.voice; @@ -26,11 +27,14 @@ class TaskSay extends Task { super.exec(cs); this.ep = ep; try { - await ep.speak({ - ttsEngine: 'google_tts', - voice: this.voice || this.callSession.speechSynthesisVoice, - text: this.text - }); + while (!this.killed && this.loop--) { + this.logger.debug(`TaskSay: remaining loops ${this.loop}`); + await ep.speak({ + ttsEngine: 'google_tts', + voice: this.voice || this.callSession.speechSynthesisVoice, + text: this.text + }); + } } catch (err) { this.logger.info(err, 'TaskSay:exec error'); } diff --git a/lib/utils/constants.json b/lib/utils/constants.json index 7f77fcd0..010d6887 100644 --- a/lib/utils/constants.json +++ b/lib/utils/constants.json @@ -30,6 +30,11 @@ "Inbound": "inbound", "Outbound": "outbound" }, + "ListenStatus": { + "Pause": "pause", + "Silence": "silence", + "Resume": "resume" + }, "TaskPreconditions": { "None": "none", "Endpoint": "endpoint", diff --git a/lib/utils/install-srf-locals.js b/lib/utils/install-srf-locals.js index c6f224f9..32e5f819 100644 --- a/lib/utils/install-srf-locals.js +++ b/lib/utils/install-srf-locals.js @@ -6,7 +6,10 @@ const PORT = process.env.HTTP_PORT || config.get('defaultHttpPort'); function installSrfLocals(srf, logger) { if (srf.locals.dbHelpers) return; - const {lookupAppByPhoneNumber} = require('jambonz-db-helpers')(config.get('mysql'), logger); + const { + lookupAppByPhoneNumber, + lookupApplicationBySid + } = require('jambonz-db-helpers')(config.get('mysql'), logger); const { updateCallStatus, retrieveCall, @@ -17,6 +20,7 @@ function installSrfLocals(srf, logger) { Object.assign(srf.locals, { dbHelpers: { lookupAppByPhoneNumber, + lookupApplicationBySid, updateCallStatus, retrieveCall, listCalls, diff --git a/package-lock.json b/package-lock.json index b70bf3ca..3e480543 100644 --- a/package-lock.json +++ b/package-lock.json @@ -813,9 +813,9 @@ "integrity": "sha512-FKPAcEMJTYKDrd9DJUCc4VHnY/c65HOO9k8XqVNognF9T02hKEjGuBCM4Da9ipyfiHmVRuECwj0XNvZ361mkVQ==" }, "drachtio-fsmrf": { - "version": "1.5.12", - "resolved": "https://registry.npmjs.org/drachtio-fsmrf/-/drachtio-fsmrf-1.5.12.tgz", - "integrity": "sha512-pj+ozJ+eg9dQH9KNOwIx+BPLyBz5qt5YKIKk1svQU/iaU/w2fvW/+EgF7RlE7Ds/1soW9vkPJNSdq0GL25MOyA==", + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/drachtio-fsmrf/-/drachtio-fsmrf-1.5.13.tgz", + "integrity": "sha512-FC/Xifua4ut5tZ9cDRCaRoEIo7LEevh5gdqgzTyKo685gm10tO//Ln7Q6ZnVnbwpFOH4TxaIf+al25z/t0v6Cg==", "requires": { "async": "^1.4.2", "debug": "^2.2.0", @@ -2062,18 +2062,18 @@ } }, "jambonz-db-helpers": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/jambonz-db-helpers/-/jambonz-db-helpers-0.2.0.tgz", - "integrity": "sha512-AykK4ICzUl5/LaNQGZdy8dlWuv8nOSSRVAqQDztJvdmJHyl4wTEC+///pKNgQlm+RX7R3vCV7dFCVoTHuIAx3A==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/jambonz-db-helpers/-/jambonz-db-helpers-0.2.4.tgz", + "integrity": "sha512-qfMKvXv//UDGFveOmeC3Xq2jMvTP7Y1P4F3EPf7VAgD10/ipozLRdEx+o3HlyF9wOeP3syha9ofpnel8VYLGLA==", "requires": { "debug": "^4.1.1", "mysql2": "^2.0.2" } }, "jambonz-realtimedb-helpers": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/jambonz-realtimedb-helpers/-/jambonz-realtimedb-helpers-0.1.3.tgz", - "integrity": "sha512-/lDhucxeR1h9wYvZ+P/UxjfzwTVxgD9IKtZWAJrBleYoLiK0MgTR2gdBThPZv7wbjU0apNcWen06Lf5nccnxQw==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/jambonz-realtimedb-helpers/-/jambonz-realtimedb-helpers-0.1.6.tgz", + "integrity": "sha512-5W7hRuPDCGeJfVLrweoNrfzQ7lCWy77+CcF4jqbTrbztZOK1rm0XhC1phCEUbghntmdLjTkwxpzEFxu7kyJKNQ==", "requires": { "bluebird": "^3.7.2", "debug": "^4.1.1", diff --git a/package.json b/package.json index 4db9f578..80b2aaf7 100644 --- a/package.json +++ b/package.json @@ -29,12 +29,12 @@ "config": "^3.2.4", "debug": "^4.1.1", "drachtio-fn-b2b-sugar": "0.0.12", - "drachtio-fsmrf": "^1.5.12", + "drachtio-fsmrf": "^1.5.13", "drachtio-srf": "^4.4.27", "express": "^4.17.1", "ip": "^1.1.5", - "jambonz-db-helpers": "^0.2.0", - "jambonz-realtimedb-helpers": "0.1.3", + "jambonz-db-helpers": "^0.2.4", + "jambonz-realtimedb-helpers": "0.1.6", "moment": "^2.24.0", "parse-url": "^5.0.1", "pino": "^5.14.0",