Compare commits

..

65 Commits

Author SHA1 Message Date
Dave Horton
b9b70debc7 bugfix: 302 response in rest outdial caused restart 2021-09-27 10:38:49 -04:00
Dave Horton
beb9c8c81d fix bug in createCall 2021-09-27 08:42:17 -04:00
Dave Horton
90ed866404 add support for overrideTo and 302 redirect on rest outdial 2021-09-24 09:59:27 -04:00
Dave Horton
4d5876a6a0 race condition: dial call killed just as called party picks up 2021-08-10 11:00:47 -04:00
Dave Horton
2cd3e6e724 bugfix: enqueue queue_result = bridged if queued call was answered 2021-08-04 08:53:08 -04:00
Dave Horton
0df937fb85 bugfix: if waitUrl of enqueue task includes leave but caller is dequeued before leave is reached, ignore leave 2021-08-03 16:36:17 -04:00
Dave Horton
3226cae04b minor logging 2021-07-06 14:57:24 -04:00
Dave Horton
29440a1c9d bugfix for transferring conferene 2021-07-02 13:22:55 -04:00
Dave Horton
2b782bc5f3 bugfix for enqueue and dial when these tasks are replaced by LCC 2021-06-29 11:04:12 -04:00
Dave Horton
9bd406fcba bugfix: undefined reference to endpoint and validation fix for wait tasks 2021-06-29 11:03:20 -04:00
Dave Horton
28df651c44 bugfix: transferring queued party to dequeuer on other FS fails if only 1 task 2021-06-28 16:40:33 -04:00
Dave Horton
05ae2c6039 bugfix: sns notifications do not require aws secrets in the env 2021-06-26 18:07:24 -04:00
Dave Horton
dc92f75529 linting 2021-06-23 11:24:40 -04:00
Dave Horton
c0a29c7a64 bugfix: if enqeue task is killed because it is being replaced with new app supplied by LCC, ignore any app returned from the actionHook as LCC takes precedence 2021-06-23 11:14:19 -04:00
Dave Horton
08c53aa586 send queue leave webhook when dequeued 2021-06-17 15:52:46 -04:00
Dave Horton
bb0ec8e184 initial changes for queue webhooks 2021-06-17 15:06:52 -04:00
Dave Horton
473a34ec9f update to latest drachtio-srf@4.4.50 for fix for 302 redirect 2021-06-03 09:42:37 -04:00
Dave Horton
686cf1b094 fix snake-case of arrays 2021-05-07 16:29:40 -04:00
Dave Horton
5cc4852bf9 snakecase fix, include sip_status in dial action hook 2021-04-27 08:21:14 -04:00
Dave Horton
576f645489 snake case REST payloads, support for LCC with child_call_hook, handle 302 on outdial 2021-04-22 14:39:54 -04:00
Dave Horton
8eb0cd1520 bugfix: speech to text was ignoring language and setting to en-US always 2021-04-07 18:40:14 -04:00
Dave Horton
e441c5be36 add support for target.overrideTo in dial verb 2021-04-06 07:34:23 -04:00
Dave Horton
dd48b5c9da update y18n 2021-03-31 07:54:41 -04:00
Dave Horton
c6168ce994 add reason property to gather action 2021-02-23 08:10:31 -05:00
Dave Horton
70e4e10a70 dialogflow tts fix and gather fix 2021-02-21 11:30:55 -05:00
Dave Horton
82768a0442 update call uses a PUT now, not POST 2021-02-19 08:48:50 -05:00
Dave Horton
8b3ffe911d bugfix in dialogflow 2021-02-18 12:59:05 -05:00
Dave Horton
a7e0fb2e8a bugfix: dep in bluebird was causing issue, update to latest synthAudio 2021-02-10 09:39:20 -05:00
Dave Horton
f8e84b5ad0 remove some unused deps 2021-02-08 15:44:09 -05:00
Dave Horton
0cff553310 update to latest realtimedb-helpers 2021-02-08 15:36:30 -05:00
Dave Horton
873729edb1 gather now supports aws for transcribe as well as google 2021-02-01 10:21:52 -05:00
Dave Horton
756db59671 update transcribe to support google v1p1beta1 and aws 2021-01-31 15:49:19 -05:00
Dave Horton
59d685319e bugfix #30 - outdial race condition for quick caller cancel scenario 2021-01-22 10:21:52 -05:00
Dave Horton
ec7a1858d6 dialogflow: clear no input timer on caller hangup 2021-01-14 08:58:18 -05:00
Dave Horton
63a00063c1 dialogflow: can optionally specify an environment 2021-01-13 21:21:26 -05:00
Dave Horton
2a8f165468 travis no longer needed -- using github actions 2021-01-08 14:07:18 -05:00
Dave Horton
d3f8e032d1 dialogflow: finish playing a final prompt before replacing application 2021-01-08 14:06:28 -05:00
Dave Horton
a1054d2d38 Merge pull request #28 from radicaldrew/master
Updated Dockerfile
2020-12-30 08:50:58 -05:00
Andrew
fa87a477ac Updated Dockerfile
created a multi stage build and tested in docker environment with compose
2020-12-30 15:34:34 +02:00
Dave Horton
69349dab75 Merge pull request #27 from radicaldrew/master
fixed uuid4 dependency and deprecation
2020-12-23 07:36:48 -05:00
Andrew Karp
b679d11fd7 fixed uui4 dependency and depraction 2020-12-23 13:20:56 +02:00
Dave Horton
ea8609b8c3 minor docs 2020-12-16 13:34:38 -05:00
Dave Horton
ef17ed40f7 include X-Account-Sid on all outgoing INVITEs 2020-12-16 13:27:02 -05:00
Dave Horton
5c5c9d9ae2 docs typo 2020-12-13 16:36:12 -05:00
Dave Horton
6e32d82364 change build status in README to github actions 2020-12-13 16:32:51 -05:00
Dave Horton
bfd8355432 Update npm-publish.yml
change name
2020-12-13 16:29:50 -05:00
Dave Horton
1a29d48334 Create npm-publish.yml 2020-12-13 16:24:02 -05:00
Dave Horton
4d6ef8e334 update deps 2020-12-13 14:27:43 -05:00
Dave Horton
cac259ec1c update to stats-collector that reconnects when socket dropped 2020-12-11 14:43:11 -05:00
Dave Horton
1bc583e805 allow dial to user without supplying sip_realm (will default to that configured for the caller account) 2020-11-29 15:00:42 -05:00
Dave Horton
16c728e246 bugfix for REST outdial to teams 2020-11-24 10:12:19 -05:00
Dave Horton
25c3512e41 lex changes 2020-11-23 09:08:48 -05:00
Dave Horton
5291824501 bugfix: sip:decline was not sending a callstatus Failed webhook 2020-11-23 09:04:22 -05:00
Dave Horton
5f908492d7 deps 2020-10-26 12:02:28 -04:00
Dave Horton
1f32170788 Merge pull request #24 from jambonz/aws-lex
Aws lex
2020-10-26 10:10:05 -04:00
Dave Horton
bd9c7b741d lex changes 2020-10-26 10:02:39 -04:00
Dave Horton
b47e490424 updates for lex v2 2020-10-26 09:59:10 -04:00
Dave Horton
6b63009707 update deps 2020-10-12 10:03:17 -04:00
Dave Horton
91f507bf3f add dmtf verb 2020-10-12 09:59:50 -04:00
Dave Horton
9d3c9accb9 update drachtio-fsrmf 2020-10-09 08:40:39 -04:00
Dave Horton
95e4c22969 add lex support 2020-10-09 08:28:36 -04:00
Dave Horton
c02aa94500 add sms messaging support 2020-10-09 08:00:17 -04:00
Dave Horton
950f1c83b7 bugfix for race condition where incoming call canceled quickly leading to potential endless loop 2020-10-01 12:46:58 -04:00
Dave Horton
e1528da8b1 bugfix #21: multiple teams target 2020-09-18 09:12:31 -04:00
Dave Horton
7abc7866dd add tts option for playing dialogflow audio 2020-09-02 12:14:53 -04:00
41 changed files with 7726 additions and 1270 deletions

View File

@@ -8,7 +8,7 @@
"jsx": false,
"modules": false
},
"ecmaVersion": 2017
"ecmaVersion": 2018
},
"plugins": ["promise"],
"rules": {

19
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created
# For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages
name: CI
on:
push:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 12
- run: npm ci
- run: npm run jslint
- run: npm test

1
.gitignore vendored
View File

@@ -39,3 +39,4 @@ examples/*
ecosystem.config.js
.vscode
run-tests.sh

View File

@@ -1,6 +0,0 @@
sudo: required
language: node_js
node_js:
- "lts/*"
script:
- npm test

View File

@@ -1,13 +1,16 @@
FROM node:lts-alpine
FROM node:alpine as builder
RUN apk update && apk add --no-cache python make g++
WORKDIR /opt/app/
COPY package.json ./
RUN npm install
RUN npm prune
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
FROM node:alpine as app
WORKDIR /opt/app
COPY . /opt/app
COPY --from=builder /opt/app/node_modules ./node_modules
ARG NODE_ENV
ENV NODE_ENV $NODE_ENV
COPY package.json /usr/src/app/
RUN npm install
COPY . /usr/src/app
CMD [ "npm", "start" ]

144
README.md
View File

@@ -1,78 +1,86 @@
# jambones-feature-server [![Build Status](https://secure.travis-ci.org/jambonz/jambones-feature-server.png)](http://travis-ci.org/jambonz/jambones-feature-server)
# jambones-feature-server ![Build Status](https://github.com/jambonz/jambonz-feature-server/workflows/CI/badge.svg)
This application implements the core feature server of the jambones platform.
## Configuration
Configuration is provided via the [npmjs config](https://www.npmjs.com/package/config) package. The following elements make up the configuration for the application:
##### drachtio server location
```
{
"drachtio": {
"port": 3001,
"secret": "cymru"
},
```
the `drachtio` object specifies the port to listen on for tcp connections from drachtio servers as well as the shared secret that is used to authenticate to the server.
Configuration is provided via environment variables:
> Note: either inbound or [outbound connections](https://drachtio.org/docs#outbound-connections) may be used, depending on the configuration supplied. In production, it is the intent to use outbound connections for easier centralization and clustering of application logic.
| variable | meaning | required?|
|----------|----------|---------|
|AWS_ACCESS_KEY_ID| aws access key id, used for TTS/STT as well SNS notifications|no|
|AWS_REGION| aws region| no|
|AWS_SECRET_ACCESS_KEY| aws secret access key, used per above|no|
|AWS_SNS_TOPIC_ARM| aws sns topic arn that scale-in lifecycle notifications will be published to|no|
|DRACHTIO_HOST| ip address of drachtio server (typically '127.0.0.1')|yes|
|DRACHTIO_PORT| listening port of drachtio server for control connections (typically 9022)|yes|
|DRACHTIO_SECRET| shared secret|yes|
|ENABLE_METRICS| if 1, metrics will be generated|no|
|GOOGLE_APPLICATION_CREDENTIALS| path to gcp service key file|yes|
|HTTP_PORT| tcp port to listen on for API requests from jambonz-api-server|yes|
|JAMBONES_FREESWITCH| IP:port:secret for Freeswitch server (e.g. '127.0.0.1:8021:JambonzR0ck$'|yes|
|JAMBONES_LOGLEVEL| log level for application, 'info' or 'debug'|no|
|JAMBONES_MYSQL_HOST| mysql host|yes|
|JAMBONES_MYSQL_USER| mysql username|yes|
|JAMBONES_MYSQL_PASSWORD| mysql password|yes|
|JAMBONES_MYSQL_DATABASE| mysql data|yes|
|JAMBONES_MYSQL_CONNECTION_LIMIT| mysql connection limit |no|
|JAMBONES_NETWORK_CIDR| CIDR of private network that feature server is running in (e.g. '172.31.0.0/16')|yes|
|JAMBONES_REDIS_HOST| redis host|yes|
|JAMBONES_REDIS_PORT|redis port|yes|
|JAMBONES_SBCS| list of IP addresses (on the internal network) of SBCs, comma-separated|yes|
|STATS_HOST| ip address of metrics host (usually '127.0.0.1' since telegraf is installed locally|no|
|STATS_PORT| listening port for metrics host|no|
|STATS_PROTOCOL| 'tcp' or 'udp'|no|
|STATS_TELEGRAF| if 1, metrics will be generated in telegraf format|no|
##### freeswitch location
```
"freeswitch: {
"address": "127.0.0.1",
"port": 8021,
"secret": "ClueCon"
},
```
the `freeswitch` property specifies the location of the freeswitch server to use for media handling.
##### application log level
```
"logging": {
"level": "info"
}
```
##### mysql server location
Login credentials for the mysql server databas.
```
"mysql": {
"host": "127.0.0.1",
"user": "jambones",
"password": "jambones",
"database": "jambones"
}
```
##### redis server location
Login credentials for the redis server databas.
```
"redis": {
"host": "127.0.0.1",
"port": 6379
}
```
##### port to listen on for HTTP API requests
The HTTP listen port can be set by the `HTTP_PORT` environment variable, but it not set the default port will be taken from the configuration file.
```
"defaultHttpPort": 3000,
```
##### REST-initiated outdials
When an outdial is triggered via the REST API, the application needs to select a drachtio sip server to generate the INVITE, and it needs to know the IP addresses of the SBC(s) to send the outbound call through. Both are provided as arrays in the configuration file, and if more than one is supplied they will be used in a round-robin fashion.
```
"outdials": {
"drachtio": [
{
"host": "127.0.0.1",
"port": 9022,
"secret": "cymru"
}
],
"sbc": ["127.0.0.1:5060"]
}
### running under pm2
Typically, this application runs under [pm2](https://pm2.io) using an [ecosystem.config.js](https://pm2.keymetrics.io/docs/usage/application-declaration/) file similar to this:
```js
module.exports = {
apps : [
{
name: 'jambonz-feature-server',
cwd: '/home/admin/apps/jambonz-feature-server',
script: 'app.js',
instance_var: 'INSTANCE_ID',
out_file: '/home/admin/.pm2/logs/jambonz-feature-server.log',
err_file: '/home/admin/.pm2/logs/jambonz-feature-server.log',
exec_mode: 'fork',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '1G',
env: {
NODE_ENV: 'production',
GOOGLE_APPLICATION_CREDENTIALS: '/home/admin/credentials/gcp.json',
AWS_ACCESS_KEY_ID: 'XXXXXXXXXXXX',
AWS_SECRET_ACCESS_KEY: 'YYYYYYYYYYYYYYYYYYYYY',
AWS_REGION: 'us-west-1',
ENABLE_METRICS: 1,
STATS_HOST: '127.0.0.1',
STATS_PORT: 8125,
STATS_PROTOCOL: 'tcp',
STATS_TELEGRAF: 1,
AWS_SNS_TOPIC_ARM: 'arn:aws:sns:us-west-1:xxxxxxxxxxx:terraform-20201107200347128600000002',
JAMBONES_NETWORK_CIDR: '172.31.0.0/16',
JAMBONES_MYSQL_HOST: 'aurora-cluster-jambonz.cluster-yyyyyyyyyyy.us-west-1.rds.amazonaws.com',
JAMBONES_MYSQL_USER: 'admin',
JAMBONES_MYSQL_PASSWORD: 'foobarbz',
JAMBONES_MYSQL_DATABASE: 'jambones',
JAMBONES_MYSQL_CONNECTION_LIMIT: 10,
JAMBONES_REDIS_HOST: 'jambonz.zzzzzzz.0001.usw1.cache.amazonaws.com',
JAMBONES_REDIS_PORT: 6379,
JAMBONES_LOGLEVEL: 'debug',
HTTP_PORT: 3000,
DRACHTIO_HOST: '127.0.0.1',
DRACHTIO_PORT: 9022,
DRACHTIO_SECRET: 'sharedsecret',
JAMBONES_SBCS: '172.31.32.10',
JAMBONES_FREESWITCH: '127.0.0.1:8021:sharedsecret'
}
}]
};
```
#### Running the test suite

View File

@@ -26,12 +26,28 @@ router.post('/', async(req, res) => {
switch (target.type) {
case 'phone':
case 'teams':
uri = `sip:${target.number}@${sbcAddress}`;
to = target.number;
if ('teams' === target.type) {
const {lookupTeamsByAccount} = srf.locals.dbHelpers;
const obj = await lookupTeamsByAccount(req.body.account_sid);
if (!obj) throw new Error('dial to ms teams not allowed; account must first be configured with teams info');
Object.assign(opts.headers, {
'X-MS-Teams-FQDN': obj.ms_teams_fqdn,
'X-MS-Teams-Tenant-FQDN': target.tenant || obj.tenant_fqdn
});
if (target.vmail === true) uri = `${uri};opaque=app:voicemail`;
}
break;
case 'user':
uri = `sip:${target.name}`;
to = target.name;
if (target.overrideTo) {
Object.assign(opts.headers, {
'X-Override-To': target.overrideTo
});
}
break;
case 'sip':
uri = target.sipUri;
@@ -78,8 +94,11 @@ router.post('/', async(req, res) => {
/* now launch the outdial */
try {
const dlg = await srf.createUAC(uri, opts, {
const dlg = await srf.createUAC(uri, {...opts, followRedirects: true, keepUriOnRedirect: true}, {
cbRequest: (err, inviteReq) => {
/* in case of 302 redirect, this gets called twice, ignore the second */
if (res.headersSent) return;
if (err) {
logger.error(err, 'createCall Error creating call');
res.status(500).send('Call Failure');
@@ -124,12 +143,14 @@ router.post('/', async(req, res) => {
if (err instanceof SipError) {
if ([486, 603].includes(err.status)) callStatus = CallStatus.Busy;
else if (487 === err.status) callStatus = CallStatus.NoAnswer;
sipLogger.info(`REST outdial failed with ${err.status}`);
cs.emit('callStatusChange', {callStatus, sipStatus: err.status});
if (sipLogger) sipLogger.info(`REST outdial failed with ${err.status}`);
else console.log(`REST outdial failed with ${err.status}`);
if (cs) cs.emit('callStatusChange', {callStatus, sipStatus: err.status});
}
else {
cs.emit('callStatusChange', {callStatus, sipStatus: 500});
sipLogger.error({err}, 'REST outdial failed');
if (cs) cs.emit('callStatusChange', {callStatus, sipStatus: 500});
if (sipLogger) sipLogger.error({err}, 'REST outdial failed');
else console.error(err);
}
ep.destroy();
}

View File

@@ -0,0 +1,35 @@
const router = require('express').Router();
const CallInfo = require('../../session/call-info');
const {CallDirection} = require('../../utils/constants');
const SmsSession = require('../../session/sms-call-session');
const normalizeJambones = require('../../utils/normalize-jambones');
const makeTask = require('../../tasks/make_task');
router.post('/:sid', async(req, res) => {
const {logger} = req.app.locals;
const {srf} = req.app.locals;
const {messageSid} = req.body;
logger.debug({body: req.body}, 'got createMessage request');
const data = [Object.assign({verb: 'message'}, req.body)];
delete data[0].messageSid;
try {
const tasks = normalizeJambones(logger, data)
.map((tdata) => makeTask(logger, tdata));
const callInfo = new CallInfo({
direction: CallDirection.None,
messageSid,
accountSid: req.params.sid,
res
});
const cs = new SmsSession({logger, srf, tasks, callInfo});
cs.exec();
} catch (err) {
logger.error({err, body: req.body}, 'OutboundSMS: error launching SmsCallSession');
}
});
module.exports = router;

View File

@@ -7,11 +7,13 @@ const {DbErrorUnprocessableRequest} = require('../utils/errors');
/**
* validate the call state
*/
function retrieveCallSession(callSid, opts) {
function retrieveCallSession(logger, callSid, opts) {
logger.debug(`retrieving session for callSid ${callSid}`);
const cs = sessionTracker.get(callSid);
if (cs) {
const task = cs.currentTask;
if (!task || task.name != TaskName.Enqueue) {
logger.debug({cs}, 'found call session but not in Enqueue task??');
throw new DbErrorUnprocessableRequest(`enqueue api failure: indicated call is not queued: ${task.name}`);
}
}
@@ -19,14 +21,14 @@ function retrieveCallSession(callSid, opts) {
}
/**
* notify a waiting session that a conference has started
* notify a waiting session that a queue event has occurred
*/
router.post('/:callSid', async(req, res) => {
const logger = req.app.locals.logger;
const callSid = req.params.callSid;
logger.debug({body: req.body}, 'got enqueue event');
logger.debug({callSid, body: req.body}, 'got enqueue event');
try {
const cs = retrieveCallSession(callSid, req.body);
const cs = retrieveCallSession(logger, callSid, req.body);
if (!cs) {
logger.info(`enqueue: callSid not found ${callSid}`);
return res.sendStatus(404);

View File

@@ -6,6 +6,9 @@ api.use('/conference', require('./conference'));
api.use('/dequeue', require('./dequeue'));
api.use('/enqueue', require('./enqueue'));
api.use('/messaging', require('./messaging')); // inbound SMS
api.use('/createMessage', require('./create-message')); // outbound SMS (REST)
// health checks
api.get('/', (req, res) => res.sendStatus(200));
api.get('/health', (req, res) => res.sendStatus(200));

View File

@@ -0,0 +1,71 @@
const router = require('express').Router();
const Requestor = require('../../utils/requestor');
const CallInfo = require('../../session/call-info');
const {CallDirection} = require('../../utils/constants');
const SmsSession = require('../../session/sms-call-session');
const normalizeJambones = require('../../utils/normalize-jambones');
const {TaskPreconditions} = require('../../utils/constants');
const makeTask = require('../../tasks/make_task');
router.post('/:partner', async(req, res) => {
const {logger} = req.app.locals;
logger.debug({body: req.body}, `got incomingSms request from partner ${req.params.partner}`);
let tasks;
const app = req.body.app;
const hook = app.messaging_hook;
const requestor = new Requestor(logger, hook);
const payload = {
provider: req.params.partner,
messageSid: app.messageSid,
accountSid: app.accountSid,
applicationSid: app.applicationSid,
from: req.body.from,
to: req.body.to,
cc: req.body.cc,
text: req.body.text,
media: req.body.media
};
res.status(200).json({sid: req.body.messageSid});
try {
tasks = await requestor.request(hook, payload);
logger.info({tasks}, 'response from incoming SMS webhook');
} catch (err) {
logger.error({err, hook}, 'Error sending incoming SMS message');
return;
}
// process any versb in response
if (Array.isArray(tasks) && tasks.length) {
const {srf} = req.app.locals;
app.requestor = requestor;
app.notifier = {request: () => {}};
try {
tasks = normalizeJambones(logger, tasks)
.map((tdata) => makeTask(logger, tdata))
.filter((t) => t.preconditions === TaskPreconditions.None);
if (0 === tasks.length) {
logger.info('inboundSMS: after removing invalid verbs there are no tasks left to execute');
return;
}
const callInfo = new CallInfo({
direction: CallDirection.None,
messageSid: app.messageSid,
accountSid: app.accountSid,
applicationSid: app.applicationSid
});
const cs = new SmsSession({logger, srf, application: app, tasks, callInfo});
cs.exec();
} catch (err) {
logger.error({err, tasks}, 'InboundSMS: error launching SmsCallSession');
}
}
});
module.exports = router;

View File

@@ -32,10 +32,7 @@ function retrieveCallSession(callSid, opts) {
return cs;
}
/**
* update a call
*/
router.post('/:callSid', async(req, res) => {
const updateCall = async(req, res) => {
const logger = req.app.locals.logger;
const callSid = req.params.callSid;
logger.debug({body: req.body}, 'got upateCall request');
@@ -45,11 +42,23 @@ router.post('/:callSid', async(req, res) => {
logger.info(`updateCall: callSid not found ${callSid}`);
return res.sendStatus(404);
}
res.sendStatus(202);
res.sendStatus(204);
cs.updateCall(req.body, callSid);
} catch (err) {
sysError(logger, res, err);
}
};
/**
* update a call
*/
/* leaving in for legacy; should have been (and now is) a PUT */
router.post('/:callSid', async(req, res) => {
await updateCall(req, res);
});
router.put('/:callSid', async(req, res) => {
await updateCall(req, res);
});
module.exports = router;

View File

@@ -1,4 +1,4 @@
const uuidv4 = require('uuid/v4');
const { v4: uuidv4 } = require('uuid');
const {CallDirection} = require('./utils/constants');
const CallInfo = require('./session/call-info');
const Requestor = require('./utils/requestor');

View File

@@ -0,0 +1,43 @@
const CallSession = require('./call-session');
/**
* @classdesc Subclass of CallSession. Represents a CallSession
* that was initially a child call leg; i.e. established via a Dial verb.
* Now it is all grown up and filling out its own CallSession. Yoo-hoo!
* @extends CallSession
*/
class AdultingCallSession extends CallSession {
constructor({logger, application, singleDialer, tasks, callInfo}) {
super({
logger,
application,
srf: singleDialer.dlg.srf,
tasks,
callInfo
});
this.sd = singleDialer;
this.sd.dlg.on('destroy', () => {
this.logger.info('AdultingCallSession: called party hung up');
this._callReleased();
});
this.sd.emit('adulting');
}
get dlg() {
return this.sd.dlg;
}
get ep() {
return this.sd.ep;
}
get callSid() {
return this.callInfo.callSid;
}
}
module.exports = AdultingCallSession;

View File

@@ -1,6 +1,6 @@
const {CallDirection, CallStatus} = require('../utils/constants');
const parseUri = require('drachtio-srf').parseUri;
const uuidv4 = require('uuid/v4');
const { v4: uuidv4 } = require('uuid');
/**
* @classdesc Represents the common information for all calls
@@ -44,6 +44,14 @@ class CallInfo {
this.callStatus = CallStatus.Trying,
this.sipStatus = 100;
}
else if (this.direction === CallDirection.None) {
// outbound SMS
const {messageSid, accountSid, applicationSid, res} = opts;
this.messageSid = messageSid;
this.accountSid = accountSid;
this.applicationSid = applicationSid;
this.res = res;
}
else {
// outbound call triggered by REST
const {req, accountSid, applicationSid, to, tag} = opts;

View File

@@ -1,13 +1,21 @@
const Emitter = require('events');
const fs = require('fs');
const {CallDirection, TaskPreconditions, CallStatus, TaskName} = require('../utils/constants');
const {CallDirection, TaskPreconditions, CallStatus, TaskName, KillReason} = require('../utils/constants');
const moment = require('moment');
const assert = require('assert');
const sessionTracker = require('./session-tracker');
const makeTask = require('../tasks/make_task');
const normalizeJambones = require('../utils/normalize-jambones');
const listTaskNames = require('../utils/summarize-tasks');
const Requestor = require('../utils/requestor');
const BADPRECONDITIONS = 'preconditions not met';
const CALLER_CANCELLED_ERR_MSG = 'Response not sent due to unknown transaction';
const sqlRetrieveQueueEventHook = `SELECT * FROM webhooks
WHERE webhook_sid =
(
SELECT queue_event_hook_sid FROM accounts where account_sid = ?
)`;
/**
* @classdesc Represents the execution context for a call.
@@ -33,18 +41,22 @@ class CallSession extends Emitter {
this.srf = srf;
this.callInfo = callInfo;
this.tasks = tasks;
this.updateCallStatus = srf.locals.dbHelpers.updateCallStatus;
this.serviceUrl = srf.locals.serviceUrl;
this.taskIdx = 0;
this.stackIdx = 0;
this.callGone = false;
this.tmpFiles = new Set();
// if this is a ConfirmSession
if (!this.isConfirmCallSession) sessionTracker.add(this.callSid, this);
if (!this.isSmsCallSession) {
this.updateCallStatus = srf.locals.dbHelpers.updateCallStatus;
this.serviceUrl = srf.locals.serviceUrl;
}
if (!this.isConfirmCallSession && !this.isSmsCallSession && !this.isAdultingCallSession) {
sessionTracker.add(this.callSid, this);
}
this._pool = srf.locals.dbHelpers.pool;
}
/**
@@ -65,7 +77,7 @@ class CallSession extends Emitter {
* SIP call-id for the call
*/
get callId() {
return this.callInfo.direction;
return this.callInfo.callId;
}
/**
@@ -158,6 +170,12 @@ class CallSession extends Emitter {
return this.application.transferredCall === true;
}
/**
* returns true if this session is a ConfirmCallSession
*/
get isAdultingCallSession() {
return this.constructor.name === 'AdultingCallSession';
}
/**
* returns true if this session is a ConfirmCallSession
*/
@@ -165,6 +183,13 @@ class CallSession extends Emitter {
return this.constructor.name === 'ConfirmCallSession';
}
/**
* returns true if this session is a SmsCallSession
*/
get isSmsCallSession() {
return this.constructor.name === 'SmsCallSession';
}
/**
* execute the tasks in the CallSession. The tasks are executed in sequence until
* they complete, or the caller hangs up.
@@ -200,7 +225,7 @@ class CallSession extends Emitter {
this._onTasksDone();
this._clearResources();
if (!this.isConfirmCallSession) sessionTracker.remove(this.callSid);
if (!this.isConfirmCallSession && !this.isSmsCallSession) sessionTracker.remove(this.callSid);
}
trackTmpFile(path) {
@@ -286,10 +311,47 @@ class CallSession extends Emitter {
* @param {object} [opts.call_hook] - new call_status_hook
*/
async _lccCallHook(opts) {
const tasks = await this.requestor.request(opts.call_hook, this.callInfo);
if (tasks && tasks.length > 0) {
this.logger.info({tasks: listTaskNames(tasks)}, 'CallSession:updateCall new task list');
this.replaceApplication(normalizeJambones(this.logger, tasks).map((tdata) => makeTask(this.logger, tdata)));
const webhooks = [];
let sd;
if (opts.call_hook) webhooks.push(this.requestor.request(opts.call_hook, this.callInfo));
if (opts.child_call_hook) {
/* child call hook only allowed from a connected Dial state */
const task = this.currentTask;
sd = task.sd;
if (task && TaskName.Dial === task.name && sd) {
webhooks.push(this.requestor.request(opts.child_call_hook, sd.callInfo));
}
}
const [tasks1, tasks2] = await Promise.all(webhooks);
let tasks, childTasks;
if (opts.call_hook) {
tasks = tasks1;
if (opts.child_call_hook) childTasks = tasks2;
}
else childTasks = tasks1;
if (childTasks) {
const {parentLogger} = this.srf.locals;
const childLogger = parentLogger.child({callId: this.callId, callSid: sd.callSid});
const t = normalizeJambones(childLogger, childTasks).map((tdata) => makeTask(childLogger, tdata));
childLogger.info({tasks: listTaskNames(t)}, 'CallSession:updateCall new task list for child call');
const cs = await sd.doAdulting({
logger: childLogger,
application: this.application,
tasks: t
});
/* need to update the callSid of the child with its own (new) AdultingCallSession */
sessionTracker.add(cs.callSid, cs);
}
if (tasks) {
const t = normalizeJambones(this.logger, tasks).map((tdata) => makeTask(this.logger, tdata));
this.logger.info({tasks: listTaskNames(t)}, 'CallSession:updateCall new task list');
this.replaceApplication(t);
}
else {
/* we started a new app on the child leg, but nothing given for parent so hang him up */
this.currentTask.kill(this);
}
}
@@ -330,8 +392,8 @@ class CallSession extends Emitter {
// this whole thing requires us to be in a Dial verb
const task = this.currentTask;
if (!task || TaskName.Dial !== task.name) {
return this.logger.info('CallSession:_lccWhisper - invalid command since we are not in a dial');
if (!task || ![TaskName.Dial, TaskName.Listen].includes(task.name)) {
return this.logger.info('CallSession:_lccWhisper - invalid command since we are not in a dial or listen');
}
// allow user to provide a url object, a url string, an array of tasks, or a single task
@@ -394,7 +456,7 @@ class CallSession extends Emitter {
if (opts.call_status) {
return this._lccCallStatus(opts);
}
if (opts.call_hook) {
if (opts.call_hook || opts.child_call_hook) {
return await this._lccCallHook(opts);
}
if (opts.listen_status) {
@@ -426,7 +488,7 @@ class CallSession extends Emitter {
this.logger.info({tasks: listTaskNames(tasks)},
`CallSession:replaceApplication reset with ${tasks.length} new tasks, stack depth is ${this.stackIdx}`);
if (this.currentTask) {
this.currentTask.kill();
this.currentTask.kill(this, KillReason.Replaced);
this.currentTask = null;
}
}
@@ -435,7 +497,7 @@ class CallSession extends Emitter {
if (this.isConfirmCallSession) this.logger.debug('CallSession:kill (ConfirmSession)');
else this.logger.info('CallSession:kill');
if (this.currentTask) {
this.currentTask.kill();
this.currentTask.kill(this);
this.currentTask = null;
}
this.tasks = [];
@@ -479,9 +541,16 @@ class CallSession extends Emitter {
const ep = await this.ms.createEndpoint({remoteSdp: this.req.body});
ep.cs = this;
this.ep = ep;
await ep.set('hangup_after_bridge', false);
ep.set({
hangup_after_bridge: false,
park_after_bridge: true
}).catch((err) => this.logger.error({err}, 'Error setting park_after_bridge'));
this.logger.debug('allocated endpoint');
this.logger.debug(`allocated endpoint ${this.ep.uuid}`);
this.ep.on('destroy', () => {
this.logger.info(`endpoint was destroyed!! ${this.ep.uuid}`);
});
if (this.direction === CallDirection.Inbound) {
if (task.earlyMedia && !this.req.finalResponseSent) {
@@ -497,8 +566,15 @@ class CallSession extends Emitter {
return ep;
} catch (err) {
this.logger.error(err, `Error attempting to allocate endpoint for for task ${task.name}`);
throw new Error(`${BADPRECONDITIONS}: unable to allocate endpoint`);
if (err === CALLER_CANCELLED_ERR_MSG) {
this.logger.error(err, 'caller canceled quickly before we could respond, ending call');
this._notifyCallStatusChange({callStatus: CallStatus.NoAnswer, sipStatus: 487});
this._callReleased();
}
else {
this.logger.error(err, `Error attempting to allocate endpoint for for task ${task.name}`);
throw new Error(`${BADPRECONDITIONS}: unable to allocate endpoint`);
}
}
}
@@ -641,6 +717,41 @@ class CallSession extends Emitter {
return {ms: this.ms, ep: this.ep};
}
/**
* If account was queue event webhook, send notification
* @param {*} obj - data to notify
*/
async performQueueWebhook(obj) {
if (typeof this.queueEventHookRequestor === 'undefined') {
const pp = this._pool.promise();
try {
this.logger.info({accountSid: this.accountSid}, 'performQueueWebhook: looking up account');
const [r] = await pp.query(sqlRetrieveQueueEventHook, this.accountSid);
if (0 === r.length) {
this.logger.info({accountSid: this.accountSid}, 'performQueueWebhook: no webhook provisioned');
this.queueEventHookRequestor = null;
}
else {
this.logger.info({accountSid: this.accountSid, webhook: r[0]}, 'performQueueWebhook: webhook found');
this.queueEventHookRequestor = new Requestor(this.logger, r[0]);
this.queueEventHook = r[0];
}
} catch (err) {
this.logger.error({err, accountSid: this.accountSid}, 'Error retrieving event hook');
this.queueEventHookRequestor = null;
}
}
if (null === this.queueEventHookRequestor) return;
/* send webhook */
const params = {...obj, ...this.callInfo.toJSON()};
this.logger.info({accountSid: this.accountSid, params}, 'performQueueWebhook: sending webhook');
this.queueEventHookRequestor.request(this.queueEventHook, params)
.catch((err) => {
this.logger.info({err, accountSid: this.accountSid, obj}, 'Error sending queue notification event');
});
}
/**
* A conference that the current task is waiting on has just started
* @param {*} opts

View File

@@ -0,0 +1,22 @@
const CallSession = require('./call-session');
/**
* @classdesc Subclass of CallSession. Represents a CallSession
* that is established for the purpose of sending an outbound SMS
* @extends CallSession
*/
class SmsCallSession extends CallSession {
constructor({logger, application, srf, tasks, callInfo}) {
super({
logger,
application,
srf,
tasks,
callInfo
});
}
}
module.exports = SmsCallSession;

View File

@@ -27,7 +27,7 @@ function camelize(str) {
function unhandled(logger, cs, evt) {
this.participantCount = parseInt(evt.getHeader('Conference-Size'));
logger.debug({evt}, `unhandled conference event: ${evt.getHeader('Action')}`) ;
logger.debug(`unhandled conference event: ${evt.getHeader('Action')}`) ;
}
function capitalize(s) {
@@ -356,7 +356,7 @@ class Conference extends Task {
}
if (typeof this.maxParticipants === 'number' && this.maxParticipants > 1) {
this.endpoint.api('conference', `${this.confName} set max_members ${this.maxParticipants}`)
this.ep.api('conference', `${this.confName} set max_members ${this.maxParticipants}`)
.catch((err) => this.logger.error(err, `Error setting max participants to ${this.maxParticipants}`));
}
}
@@ -448,16 +448,16 @@ class Conference extends Task {
async _playHook(cs, dlg, hook, allowed = [TaskName.Play, TaskName.Say, TaskName.Pause]) {
assert(!this._playSession);
const json = await cs.application.requestor.request(hook, cs.callInfo);
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
const allowedTasks = json.filter((task) => allowed.includes(task.verb));
if (json.length !== allowedTasks.length) {
this.logger.debug({json, allowedTasks}, 'unsupported task');
throw new Error(`unsupported verb in dial conference wait/enterHook: only ${JSON.stringify(allowed)}`);
const allowedTasks = tasks.filter((t) => allowed.includes(t.name));
if (tasks.length !== allowedTasks.length) {
this.logger.debug({tasks, allowedTasks}, 'unsupported task');
throw new Error(`unsupported verb in conference waitHook: only ${JSON.stringify(allowed)}`);
}
this.logger.debug(`Conference:_playHook: executing ${json.length} tasks`);
this.logger.debug(`Conference:_playHook: executing ${tasks.length} tasks`);
if (json.length > 0) {
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
if (tasks.length > 0) {
this._playSession = new ConfirmCallSession({
logger: this.logger,
application: cs.application,
@@ -514,9 +514,6 @@ class Conference extends Task {
const functionName = `_on${capitalize(camelize(action))}`;
(Conference.prototype[functionName] || unhandled).bind(this, this.logger, cs, evt)() ;
}
else {
this.logger.debug(`Conference#__onConferenceEvent: got unhandled custom event: ${eventName}`) ;
}
}
// conference event handlers

View File

@@ -110,7 +110,8 @@ class TaskDequeue extends Task {
event: 'dequeue',
dequeueSipAddress: cs.srf.locals.localSipAddress,
epUuid: ep.uuid,
notifyUrl: getUrl(cs)
notifyUrl: getUrl(cs),
dequeuer: cs.callInfo.toJSON()
});
this.logger.info(`TaskDequeue:_dequeueUrl successfully sent POST to ${url}`);
bridgeTimer = setTimeout(() => reject(new Error('bridge timeout')), 20000);

View File

@@ -1,6 +1,13 @@
const Task = require('./task');
const makeTask = require('./make_task');
const {CallStatus, CallDirection, TaskName, TaskPreconditions, MAX_SIMRINGS} = require('../utils/constants');
const {
CallStatus,
CallDirection,
TaskName,
TaskPreconditions,
MAX_SIMRINGS,
KillReason
} = require('../utils/constants');
const assert = require('assert');
const placeCall = require('../utils/place-outdial');
const sessionTracker = require('../session/session-tracker');
@@ -113,6 +120,11 @@ class TaskDial extends Task {
}
get ep() {
/**
* Note:
* this.ep is the B leg-facing endpoint
* this.epOther is the A leg-facing endpoint
*/
if (this.sd) return this.sd.ep;
}
@@ -133,7 +145,9 @@ class TaskDial extends Task {
this._installDtmfDetection(cs, this.epOther, this.parentDtmfCollector);
await this._attemptCalls(cs);
await this.awaitTaskDone();
await this.performAction(this.results);
this.logger.debug({callSid: this.cs.callSid}, 'Dial:exec task is done, sending actionHook if any');
await this.performAction(this.results, this.killReason !== KillReason.Replaced);
this._removeDtmfDetection(cs, this.epOther);
this._removeDtmfDetection(cs, this.ep);
} catch (err) {
@@ -142,10 +156,13 @@ class TaskDial extends Task {
}
}
async kill(cs) {
async kill(cs, reason) {
super.kill(cs);
this.killReason = reason || KillReason.Hangup;
this._removeDtmfDetection(this.cs, this.epOther);
this._removeDtmfDetection(this.cs, this.ep);
this.logger.debug({callSid: this.cs.callSid}, 'Dial:kill removed dtmf listeners');
this._killOutdials();
if (this.sd) {
this.sd.kill();
@@ -212,6 +229,7 @@ class TaskDial extends Task {
_removeDtmfDetection(cs, ep) {
if (ep) {
delete ep.dtmfDetector;
this.logger.debug(`Dial:_removeDtmfDetection endpoint ${ep.uuid}`);
ep.removeAllListeners('dtmf');
}
}
@@ -219,13 +237,18 @@ class TaskDial extends Task {
_onDtmf(cs, ep, evt) {
if (ep.dtmfDetector) {
const match = ep.dtmfDetector.keyPress(evt.dtmf);
const requestor = ep.dtmfDetector === this.parentDtmfCollector ?
cs.requestor :
this.sd.requestor;
if (match) {
this.logger.debug(`parentCall triggered dtmf match: ${match}`);
requestor.request(this.dtmfHook, Object.assign({dtmf: match}, cs.callInfo))
.catch((err) => this.logger.info(err, 'Dial:_onDtmf - error'));
this.logger.debug({callSid: this.cs.callSid}, `Dial:_onDtmf triggered dtmf match: ${match}`);
const requestor = ep.dtmfDetector === this.parentDtmfCollector ?
cs.requestor :
(this.sd ? this.sd.requestor : null);
if (!requestor) {
this.logger.info(`Dial:_onDtmf got digits on B leg after adulting: ${evt.dtmf}`);
}
else {
requestor.request(this.dtmfHook, Object.assign({dtmf: match}, cs.callInfo))
.catch((err) => this.logger.info(err, 'Dial:_onDtmf - error'));
}
}
}
}
@@ -246,9 +269,10 @@ class TaskDial extends Task {
async _attemptCalls(cs) {
const {req, srf} = cs;
const {getSBC} = srf.locals;
const {lookupTeamsByAccount} = srf.locals.dbHelpers;
const {lookupTeamsByAccount, lookupAccountBySid} = srf.locals.dbHelpers;
const sbcAddress = this.proxy || getSBC();
const teamsInfo = {};
let fqdn;
if (!sbcAddress) throw new Error('no SBC found for outbound call');
const opts = {
@@ -256,6 +280,7 @@ class TaskDial extends Task {
proxy: `sip:${sbcAddress}`,
callingNumber: this.callerId || req.callingNumber
};
Object.assign(opts.headers, {'X-Account-Sid': cs.accountSid});
const t = this.target.find((t) => t.type === 'teams');
if (t) {
@@ -270,11 +295,23 @@ class TaskDial extends Task {
this._killOutdials();
}, this.timeout * 1000);
this.target.forEach((t) => {
this.target.forEach(async(t) => {
try {
t.url = t.url || this.confirmUrl;
t.method = t.method || this.confirmMethod || 'POST';
if (t.type === 'teams') t.teamsInfo = teamsInfo;
if (t.type === 'user' && !t.name.includes('@') && !fqdn) {
const user = t.name;
try {
const {sip_realm} = await lookupAccountBySid(cs.accountSid);
if (sip_realm) {
t.name = `${user}@${sip_realm}`;
this.logger.debug(`appending sip realm ${sip_realm} to dial target user ${user}`);
}
} catch (err) {
this.logger.error({err}, 'Error looking up account by sid');
}
}
const sd = placeCall({
logger: this.logger,
application: cs.application,
@@ -299,6 +336,7 @@ class TaskDial extends Task {
if (this.results.dialCallStatus !== CallStatus.Completed) {
Object.assign(this.results, {
dialCallStatus: obj.callStatus,
dialSipStatus: obj.sipStatus,
dialCallSid: sd.callSid,
});
}
@@ -337,6 +375,16 @@ class TaskDial extends Task {
this.logger.debug('Dial:_attemptCalls - all calls failed after decline, ending task');
this.kill(cs);
}
})
.once('adulting', () => {
/* child call just adulted and got its own session */
this.logger.info('Dial:on_adulting: detaching child call leg');
if (this.ep) {
this.logger.debug(`Dial:on_adulting: removing dtmf from ${this.ep.uuid}`);
this.ep.removeAllListeners('dtmf');
}
this.sd = null;
this.callSid = null;
});
} catch (err) {
this.logger.error(err, 'Dial:_attemptCalls');
@@ -385,15 +433,19 @@ class TaskDial extends Task {
}
sessionTracker.add(this.callSid, cs);
this.dlg.on('destroy', () => {
this.logger.debug('Dial:_selectSingleDial called party hungup, ending dial operation');
sessionTracker.remove(this.callSid);
if (this.timerMaxCallDuration) clearTimeout(this.timerMaxCallDuration);
this.ep.unbridge();
this.kill(cs);
/* if our child is adulting, he's own his own now.. */
if (this.dlg) {
this.logger.debug('Dial:_selectSingleDial called party hungup, ending dial operation');
sessionTracker.remove(this.callSid);
if (this.timerMaxCallDuration) clearTimeout(this.timerMaxCallDuration);
this.ep.unbridge();
this.kill(cs);
}
});
Object.assign(this.results, {
dialCallStatus: CallStatus.Completed,
dialSipStatus: 200,
dialCallSid: sd.callSid,
});

View File

@@ -11,7 +11,8 @@ class Dialogflow extends Task {
this.preconditions = TaskPreconditions.Endpoint;
this.credentials = this.data.credentials;
this.project = this.data.project;
if (this.data.environment) this.project = `${this.data.project}:${this.data.environment}`;
else this.project = this.data.project;
this.lang = this.data.lang || 'en-US';
this.welcomeEvent = this.data.welcomeEvent || '';
if (this.welcomeEvent.length && this.data.welcomeEventParams && typeof this.data.welcomeEventParams === 'object') {
@@ -41,6 +42,12 @@ class Dialogflow extends Task {
}
if (this.data.actionHook) this.actionHook = this.data.actionHook;
if (this.data.thinkingMusic) this.thinkingMusic = this.data.thinkingMusic;
if (this.data.tts) {
this.vendor = this.data.tts.vendor || 'default';
this.language = this.data.tts.language || 'default';
this.voice = this.data.tts.voice || 'default';
}
this.bargein = this.data.bargein;
}
get name() { return TaskName.Dialogflow; }
@@ -54,13 +61,12 @@ class Dialogflow extends Task {
this.logger.debug(`starting dialogflow bot ${this.project}`);
// kick it off
const baseArgs = `${this.ep.uuid} ${this.project} ${this.lang} ${this.welcomeEvent}`;
if (this.welcomeEventParams) {
this.ep.api('dialogflow_start',
`${this.ep.uuid} ${this.project} ${this.lang} ${this.welcomeEvent} '${JSON.stringify(this.welcomeEventParams)}'`);
this.ep.api('dialogflow_start', `${baseArgs} '${JSON.stringify(this.welcomeEventParams)}'`);
}
else if (this.welcomeEvent.length) {
this.ep.api('dialogflow_start',
`${this.ep.uuid} ${this.project} ${this.lang} ${this.welcomeEvent}`);
this.ep.api('dialogflow_start', baseArgs);
}
else {
this.ep.api('dialogflow_start', `${this.ep.uuid} ${this.project} ${this.lang}`);
@@ -83,7 +89,9 @@ class Dialogflow extends Task {
this.ep.removeCustomEventListener('dialogflow::end_of_utterance');
this.ep.removeCustomEventListener('dialogflow::error');
this.performAction({dialogflowResult: 'caller hungup'})
this._clearNoinputTimer();
if (!this.reportedFinalAction) this.performAction({dialogflowResult: 'caller hungup'})
.catch((err) => this.logger.error({err}, 'dialogflow - error w/ action webook'));
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
@@ -94,6 +102,11 @@ class Dialogflow extends Task {
async init(cs, ep) {
this.ep = ep;
try {
if (this.vendor === 'default') {
this.vendor = cs.speechSynthesisVendor;
this.language = cs.speechSynthesisLanguage;
this.voice = cs.speechSynthesisVoice;
}
this.ep.addCustomEventListener('dialogflow::intent', this._onIntent.bind(this, ep, cs));
this.ep.addCustomEventListener('dialogflow::transcription', this._onTranscription.bind(this, ep, cs));
this.ep.addCustomEventListener('dialogflow::audio_provided', this._onAudioProvided.bind(this, ep, cs));
@@ -117,7 +130,7 @@ class Dialogflow extends Task {
* @param {*} ep - media server endpoint
* @param {*} evt - event data
*/
_onIntent(ep, cs, evt) {
async _onIntent(ep, cs, evt) {
const intent = new Intent(this.logger, evt);
if (intent.isEmpty) {
@@ -143,6 +156,7 @@ class Dialogflow extends Task {
}
else {
this.logger.info('got empty intent');
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang}`);
}
return;
}
@@ -178,6 +192,71 @@ class Dialogflow extends Task {
this.digitBuffer = new DigitBuffer(this.logger, opts);
this.digitBuffer.once('fulfilled', this._onDtmfEntryComplete.bind(this, ep));
}
/* if we are using tts and a message was provided, play it out */
if (this.vendor && intent.fulfillmentText && intent.fulfillmentText.length > 0) {
const {srf} = cs;
const {synthAudio} = srf.locals.dbHelpers;
this.waitingForPlayStart = false;
// start a new intent, (we want to continue to listen during the audio playback)
// _unless_ we are transferring or ending the session
if (!this.hangupAfterPlayDone) {
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang}`);
}
try {
const obj = {
text: intent.fulfillmentText,
vendor: this.vendor,
language: this.language,
voice: this.voice,
salt: cs.callSid
};
this.logger.debug({obj}, 'Dialogflow:_onIntent - playing message via tts');
const {filePath} = await synthAudio(obj);
if (filePath) cs.trackTmpFile(filePath);
if (this.playInProgress) {
await ep.api('uuid_break', ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
}
this.playInProgress = true;
this.curentAudioFile = filePath;
this.logger.debug(`starting to play tts ${filePath}`);
if (this.events.includes('start-play')) {
this._performHook(cs, this.eventHook, {event: 'start-play', data: {path: filePath}});
}
await ep.play(filePath);
if (this.events.includes('stop-play')) {
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: filePath}});
}
this.logger.debug(`finished ${filePath}`);
if (this.curentAudioFile === filePath) {
this.playInProgress = false;
if (this.queuedTasks) {
this.logger.debug('finished playing audio and we have queued tasks');
this._redirect(cs, this.queuedTasks);
return;
}
}
this.greetingPlayed = true;
if (this.hangupAfterPlayDone) {
this.logger.info('hanging up since intent was marked end interaction and we completed final prompt');
this.performAction({dialogflowResult: 'completed'});
this.notifyTaskDone();
}
else {
// every time we finish playing a prompt, start the no-input timer
this._startNoinputTimer(ep, cs);
}
} catch (err) {
this.logger.error({err}, 'Dialogflow:_onIntent - error playing tts');
}
}
}
/**
@@ -188,7 +267,7 @@ class Dialogflow extends Task {
* @param {*} ep - media server endpoint
* @param {*} evt - event data
*/
_onTranscription(ep, cs, evt) {
async _onTranscription(ep, cs, evt) {
const transcription = new Transcription(this.logger, evt);
if (this.events.includes('transcription') && transcription.isFinal) {
@@ -203,6 +282,13 @@ class Dialogflow extends Task {
transcription.confidence > 0.8) {
ep.play(this.data.thinkingSound).catch((err) => this.logger.info(err, 'Error playing typing sound'));
}
// interrupt playback on speaking if bargein = true
if (this.bargein && this.playInProgress) {
this.logger.debug('terminating playback due to speech bargein');
this.playInProgress = false;
await ep.api('uuid_break', ep.uuid);
}
}
/**
@@ -231,6 +317,9 @@ class Dialogflow extends Task {
* @param {*} evt - event data
*/
async _onAudioProvided(ep, cs, evt) {
if (this.vendor) return;
this.waitingForPlayStart = false;
// kill filler audio
@@ -253,10 +342,16 @@ class Dialogflow extends Task {
if (this.events.includes('stop-play')) {
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: evt.path}});
}
this.logger.info(`finished ${evt.path}`);
this.logger.info(`finished ${evt.path}, queued tasks: ${(this.queuedTasks || []).length}`);
if (this.curentAudioFile === evt.path) {
this.playInProgress = false;
if (this.queuedTasks) {
this.logger.debug('finished playing audio and we have queued tasks');
this._redirect(cs, this.queuedTasks);
this.queuedTasks.length = 0;
return;
}
}
/*
if (!this.inbound && !this.greetingPlayed) {
@@ -346,13 +441,24 @@ class Dialogflow extends Task {
const makeTask = require('../make_task');
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
if (tasks && tasks.length > 0) {
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
this.performAction({dialogflowResult: 'redirect'}, false);
cs.replaceApplication(tasks);
if (this.playInProgress) {
this.queuedTasks = tasks;
this.logger.info({tasks: tasks},
`${this.name} replacing application with ${tasks.length} tasks after play completes`);
return;
}
this._redirect(cs, tasks);
}
}
}
_redirect(cs, tasks) {
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
this.performAction({dialogflowResult: 'redirect'}, false);
this.reportedFinalAction = true;
cs.replaceApplication(tasks);
}
}
module.exports = Dialogflow;

41
lib/tasks/dtmf.js Normal file
View File

@@ -0,0 +1,41 @@
const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants');
class TaskDtmf extends Task {
constructor(logger, opts) {
super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint;
this.dtmf = this.data.dtmf;
this.duration = this.data.duration || 500;
}
get name() { return TaskName.Dtmf; }
async exec(cs, ep) {
await super.exec(cs);
this.ep = ep;
try {
this.logger.info({data: this.data}, `sending dtmf ${this.dtmf}`);
await this.ep.execute('send_dtmf', `${this.dtmf}@${this.duration}`);
this.timer = setTimeout(this.notifyTaskDone.bind(this), this.dtmf.length * (this.duration + 250) + 750);
await this.awaitTaskDone();
this.logger.info({data: this.data}, `done sending dtmf ${this.dtmf}`);
} catch (err) {
this.logger.info(err, `TaskDtmf:exec - error playing ${this.dtmf}`);
}
this.emit('playDone');
}
async kill(cs) {
super.kill(cs);
if (this.ep.connected && !this.playComplete) {
this.logger.debug('TaskDtmf:kill - killing audio');
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
}
clearTimeout(this.timer);
this.notifyTaskDone();
}
}
module.exports = TaskDtmf;

View File

@@ -3,7 +3,7 @@ const Emitter = require('events');
const ConfirmCallSession = require('../session/confirm-call-session');
const normalizeJambones = require('../utils/normalize-jambones');
const makeTask = require('./make_task');
const {TaskName, TaskPreconditions, QueueResults} = require('../utils/constants');
const {TaskName, TaskPreconditions, QueueResults, KillReason} = require('../utils/constants');
const bent = require('bent');
const assert = require('assert');
@@ -61,9 +61,10 @@ class TaskEnqueue extends Task {
}
}
async kill(cs) {
async kill(cs, reason) {
super.kill(cs);
this.logger.info(`TaskEnqueue:kill ${this.queueName}`);
this.killReason = reason || KillReason.Hangup;
this.logger.info(`TaskEnqueue:kill ${this.queueName} with reason ${this.killReason}`);
this.emitter.emit('kill');
this.notifyTaskDone();
}
@@ -76,11 +77,20 @@ class TaskEnqueue extends Task {
const members = await pushBack(this.queueName, url);
this.logger.info(`TaskEnqueue:_addToQueue: added to queue, length now ${members}`);
this.notifyUrl = url;
/* invoke account-level webhook for queue event notifications */
cs.performQueueWebhook({
event: 'join',
queue: this.data.name,
length: members,
joinTime: this.waitStartTime
});
}
async _removeFromQueue(cs, dlg) {
const {removeFromList} = cs.srf.locals.dbHelpers;
return await removeFromList(this.queueName, getUrl(cs));
async _removeFromQueue(cs) {
const {removeFromList, lengthOfList} = cs.srf.locals.dbHelpers;
await removeFromList(this.queueName, getUrl(cs));
return await lengthOfList(this.queueName);
}
async performAction() {
@@ -89,7 +99,7 @@ class TaskEnqueue extends Task {
queueTime: getElapsedTime(this.waitStartTime),
queueResult: this.state
};
await super.performAction(params);
await super.performAction(params, this.killReason !== KillReason.Replaced);
}
/**
@@ -104,13 +114,28 @@ class TaskEnqueue extends Task {
this.bridgeDetails = opts;
this.logger.info({bridgeDetails: this.bridgeDetails}, `time to dequeue from ${this.queueName}`);
if (this._playSession) {
this._leave = false;
this._playSession.kill();
this._playSession = null;
}
resolve(this._doBridge(cs, dlg, ep));
})
.once('kill', () => {
this._removeFromQueue(cs);
.once('kill', async() => {
/* invoke account-level webhook for queue event notifications */
if (!this.dequeued) {
try {
const members = await this._removeFromQueue(cs);
cs.performQueueWebhook({
event: 'leave',
queue: this.data.name,
length: members,
leaveReason: this.killReason !== KillReason.Replaced ? 'abandoned' : 'redirected',
leaveTime: Date.now()
});
} catch (err) {}
}
if (this._playSession) {
this.logger.debug('killing waitUrl');
this._playSession.kill();
@@ -209,6 +234,7 @@ class TaskEnqueue extends Task {
});
// resolve when either side hangs up
this.state = QueueResults.Bridged;
this.emitter
.on('hangup', () => {
this.logger.info('TaskEnqueue:_bridgeLocal ending with hangup from dequeue party');
@@ -216,7 +242,7 @@ class TaskEnqueue extends Task {
resolve();
})
.on('kill', () => {
this.logger.info('TaskEnqueue:_bridgeLocal ending with hangup from enqeue party');
this.logger.info(`TaskEnqueue:_bridgeLocal ending with ${this.killReason}`);
ep.unbridge().catch((err) => {});
// notify partner that we dropped
@@ -242,12 +268,26 @@ class TaskEnqueue extends Task {
* @param {string} opts.epUuid uuid of the endpoint we need to bridge to
* @param {string} opts.dequeueSipAddress ip:port of the feature server hosting the other call
*/
notifyQueueEvent(cs, opts) {
async notifyQueueEvent(cs, opts) {
if (opts.event === 'dequeue') {
if (this.bridgeNow) return;
this.logger.info({opts}, `TaskEnqueue:notifyDequeueEvent: leaving ${this.queueName} because someone wants me`);
assert(opts.dequeueSipAddress && opts.epUuid && opts.notifyUrl);
this.emitter.emit('dequeue', opts);
try {
const {lengthOfList} = cs.srf.locals.dbHelpers;
const members = await lengthOfList(this.queueName);
this.dequeued = true;
cs.performQueueWebhook({
event: 'leave',
queue: this.data.name,
length: Math.max(members - 1, 0),
leaveReason: 'dequeued',
leaveTime: Date.now(),
dequeuer: opts.dequeuer
});
} catch (err) {}
}
else if (opts.event === 'hangup') {
this.emitter.emit('hangup');
@@ -275,20 +315,20 @@ class TaskEnqueue extends Task {
this.logger.error({err}, `TaskEnqueue:_playHook error retrieving list info for queue ${this.queueName}`);
}
const json = await cs.application.requestor.request(hook, params);
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
const allowedTasks = json.filter((task) => allowed.includes(task.verb));
if (json.length !== allowedTasks.length) {
this.logger.debug({json, allowedTasks}, 'unsupported task');
throw new Error(`unsupported verb in dial enqueue waitHook: only ${JSON.stringify(allowed)}`);
const allowedTasks = tasks.filter((t) => allowed.includes(t.name));
if (tasks.length !== allowedTasks.length) {
this.logger.debug({tasks, allowedTasks}, 'unsupported task');
throw new Error(`unsupported verb in enqueue waitHook: only ${JSON.stringify(allowed)}`);
}
this.logger.debug(`TaskEnqueue:_playHook: executing ${json.length} tasks`);
this.logger.debug(`TaskEnqueue:_playHook: executing ${tasks.length} tasks`);
// check for 'leave' verb and only execute tasks up till then
const tasksToRun = [];
let leave = false;
for (const o of json) {
if (o.verb === TaskName.Leave) {
leave = true;
for (const o of tasks) {
if (o.name === TaskName.Leave) {
this._leave = true;
this.logger.info('waitHook returned a leave task');
break;
}
@@ -297,19 +337,18 @@ class TaskEnqueue extends Task {
if (this.killed) return [];
else if (tasksToRun.length > 0) {
const tasks = normalizeJambones(this.logger, tasksToRun).map((tdata) => makeTask(this.logger, tdata));
this._playSession = new ConfirmCallSession({
logger: this.logger,
application: cs.application,
dlg,
ep: cs.ep,
callInfo: cs.callInfo,
tasks
tasks: tasksToRun
});
await this._playSession.exec();
this._playSession = null;
}
if (leave) {
if (this._leave) {
this.state = QueueResults.Leave;
this.kill(cs);
}

View File

@@ -1,5 +1,11 @@
const Task = require('./task');
const {TaskName, TaskPreconditions, TranscriptionEvents} = require('../utils/constants');
const {
TaskName,
TaskPreconditions,
GoogleTranscriptionEvents,
AwsTranscriptionEvents
} = require('../utils/constants');
const makeTask = require('./make_task');
const assert = require('assert');
@@ -10,15 +16,23 @@ class TaskGather extends Task {
[
'finishOnKey', 'hints', 'input', 'numDigits',
'partialResultHook', 'profanityFilter',
'partialResultHook',
'speechTimeout', 'timeout', 'say', 'play'
].forEach((k) => this[k] = this.data[k]);
this.timeout = (this.timeout || 5) * 1000;
this.interim = this.partialResultCallback;
if (this.data.recognizer) {
this.language = this.data.recognizer.language || 'en-US';
this.vendor = this.data.recognizer.vendor;
const recognizer = this.data.recognizer;
this.vendor = recognizer.vendor;
this.language = recognizer.language;
this.hints = recognizer.hints || [];
this.altLanguages = recognizer.altLanguages || [];
/* aws options */
this.vocabularyName = recognizer.vocabularyName;
this.vocabularyFilterName = recognizer.vocabularyFilterName;
this.filterMethod = recognizer.filterMethod;
}
this.digitBuffer = '';
@@ -38,6 +52,8 @@ class TaskGather extends Task {
async exec(cs, ep) {
await super.exec(cs);
this.ep = ep;
if ('default' === this.vendor || !this.vendor) this.vendor = cs.speechRecognizerVendor;
if ('default' === this.language || !this.language) this.language = cs.speechRecognizerLanguage;
try {
if (this.sayTask) {
@@ -67,13 +83,15 @@ class TaskGather extends Task {
} catch (err) {
this.logger.error(err, 'TaskGather:exec error');
}
ep.removeCustomEventListener(TranscriptionEvents.Transcription);
ep.removeCustomEventListener(TranscriptionEvents.EndOfUtterance);
ep.removeCustomEventListener(GoogleTranscriptionEvents.Transcription);
ep.removeCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance);
ep.removeCustomEventListener(AwsTranscriptionEvents.Transcription);
}
kill(cs) {
super.kill(cs);
this._killAudio();
this.ep.removeAllListeners('dtmf');
this._resolve('killed');
}
@@ -88,33 +106,54 @@ class TaskGather extends Task {
}
async _initSpeech(ep) {
const opts = {
GOOGLE_SPEECH_USE_ENHANCED: true,
GOOGLE_SPEECH_SINGLE_UTTERANCE: true,
GOOGLE_SPEECH_MODEL: 'command_and_search'
};
if (this.hints) {
Object.assign(opts, {'GOOGLE_SPEECH_HINTS': this.hints.join(',')});
const opts = {};
if ('google' === this.vendor) {
Object.assign(opts, {
GOOGLE_SPEECH_USE_ENHANCED: true,
GOOGLE_SPEECH_SINGLE_UTTERANCE: true,
GOOGLE_SPEECH_MODEL: 'command_and_search'
});
if (this.hints && this.hints.length > 1) opts.GOOGLE_SPEECH_HINTS = this.hints.join(',');
if (this.altLanguages && this.altLanguages.length > 1) {
opts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
}
if (this.profanityFilter === true) {
Object.assign(opts, {'GOOGLE_SPEECH_PROFANITY_FILTER': true});
}
}
if (this.profanityFilter === true) {
Object.assign(opts, {'GOOGLE_SPEECH_PROFANITY_FILTER': true});
else {
if (this.vocabularyName) opts.AWS_VOCABULARY_NAME = this.vocabularyName;
if (this.vocabularyFilterName) {
opts.AWS_VOCABULARY_NAME = this.vocabularyFilterName;
opts.AWS_VOCABULARY_FILTER_METHOD = this.filterMethod || 'mask';
}
Object.assign(opts, {
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
AWS_REGION: process.env.AWS_REGION
});
}
this.logger.debug(`setting freeswitch vars ${JSON.stringify(opts)}`);
await ep.set(opts)
.catch((err) => this.logger.info(err, 'Error set'));
ep.addCustomEventListener(TranscriptionEvents.Transcription, this._onTranscription.bind(this, ep));
ep.addCustomEventListener(TranscriptionEvents.EndOfUtterance, this._onEndOfUtterance.bind(this, ep));
.catch((err) => this.logger.info(err, 'Error setting channel variables'));
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, this._onTranscription.bind(this, ep));
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, this._onTranscription.bind(this, ep));
ep.addCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance, this._onEndOfUtterance.bind(this, ep));
}
_startTranscribing(ep) {
ep.startTranscription({
vendor: this.vendor,
locale: this.language,
interim: this.partialResultCallback ? true : false,
language: this.language || this.callSession.speechRecognizerLanguage
}).catch((err) => this.logger.error(err, 'TaskGather:_startTranscribing error'));
}
_startTimer() {
assert(!this._timeoutTimer);
this.logger.debug(`Gather:_startTimer: timeout ${this.timeout}`);
this._timeoutTimer = setTimeout(() => this._resolve('timeout'), this.timeout);
}
@@ -137,8 +176,10 @@ class TaskGather extends Task {
}
_onTranscription(ep, evt) {
if ('aws' === this.vendor && Array.isArray(evt) && evt.length > 0) evt = evt[0];
this.logger.debug(evt, 'TaskGather:_onTranscription');
if (evt.is_final) this._resolve('speech', evt);
const final = evt.is_final;
if (final) this._resolve('speech', evt);
else if (this.partialResultHook) {
this.cs.requestor.request(this.partialResultHook, Object.assign({speech: evt}, this.cs.callInfo))
.catch((err) => this.logger.info(err, 'GatherTask:_onTranscription error'));
@@ -155,15 +196,19 @@ class TaskGather extends Task {
this.logger.debug(`TaskGather:resolve with reason ${reason}`);
if (this.ep && this.ep.connected) {
this.ep.stopTranscription().catch((err) => this.logger.error({err}, 'Error stopping transcription'));
this.ep.stopTranscription({vendor: this.vendor})
.catch((err) => this.logger.error({err}, 'Error stopping transcription'));
}
this._clearTimer();
if (reason.startsWith('dtmf')) {
await this.performAction({digits: this.digitBuffer});
await this.performAction({reason: 'dtmfDetected', digits: this.digitBuffer});
}
else if (reason.startsWith('speech')) {
await this.performAction({speech: evt});
await this.performAction({reason: 'speechDetected', speech: evt});
}
else if (reason.startsWith('timeout')) {
await this.performAction({reason: 'inputTimeout'});
}
this.notifyTaskDone();
}

300
lib/tasks/lex.js Normal file
View File

@@ -0,0 +1,300 @@
const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants');
const normalizeJambones = require('../utils/normalize-jambones');
class Lex extends Task {
constructor(logger, opts) {
super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint;
if (this.data.credentials) {
this.awsAccessKeyId = this.data.credentials.accessKey;
this.awsSecretAccessKey = this.data.credentials.secretAccessKey;
}
this.bot = this.data.botId;
this.alias = this.data.botAlias;
this.region = this.data.region;
this.locale = this.data.locale || 'en_US';
this.intent = this.data.intent || {};
this.metadata = this.data.metadata;
this.welcomeMessage = this.data.welcomeMessage;
this.bargein = this.data.bargein || false;
this.passDtmf = this.data.passDtmf || false;
if (this.data.noInputTimeout) this.noInputTimeout = this.data.noInputTimeout * 1000;
if (this.data.tts) {
this.vendor = this.data.tts.vendor || 'default';
this.language = this.data.tts.language || 'default';
this.voice = this.data.tts.voice || 'default';
}
this.botName = `${this.bot}:${this.alias}:${this.region}`;
if (this.data.eventHook) this.eventHook = this.data.eventHook;
this.events = this.eventHook ?
[
'intent',
'transcription',
'dtmf',
'start-play',
'stop-play',
'play-interrupted',
'response-text'
] : [];
if (this.data.actionHook) this.actionHook = this.data.actionHook;
}
get name() { return TaskName.Lex; }
async exec(cs, ep) {
await super.exec(cs);
try {
await this.init(cs, ep);
// kick it off
const obj = {};
let cmd = `${this.ep.uuid} ${this.bot} ${this.alias} ${this.region} ${this.locale} `;
if (this.metadata) Object.assign(obj, this.metadata);
if (this.intent.name) {
cmd += this.intent.name;
if (this.intent.slots) Object.assign(obj, {slots: this.intent.slots});
}
if (Object.keys(obj).length > 0) cmd += ` '${JSON.stringify(obj)}'`;
this.logger.debug({cmd}, `starting lex bot ${this.botName} with locale ${this.locale}`);
this.ep.api('aws_lex_start', cmd)
.catch((err) => {
this.logger.error({err}, `Error starting lex bot ${this.botName}`);
this.notifyTaskDone();
});
await this.awaitTaskDone();
} catch (err) {
this.logger.error({err}, 'Lex:exec error');
}
}
async kill(cs) {
super.kill(cs);
if (this.ep.connected) {
this.logger.debug('Lex:kill');
this.ep.removeCustomEventListener('lex::intent');
this.ep.removeCustomEventListener('lex::transcription');
this.ep.removeCustomEventListener('lex::audio_provided');
this.ep.removeCustomEventListener('lex::text_response');
this.ep.removeCustomEventListener('lex::playback_interruption');
this.ep.removeCustomEventListener('lex::error');
this.ep.removeAllListeners('dtmf');
this.performAction({lexResult: 'caller hungup'})
.catch((err) => this.logger.error({err}, 'lex - error w/ action webook'));
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
}
this.notifyTaskDone();
}
async init(cs, ep) {
this.ep = ep;
try {
if (this.vendor === 'default') {
this.vendor = cs.speechSynthesisVendor;
this.language = cs.speechSynthesisLanguage;
this.voice = cs.speechSynthesisVoice;
}
this.ep.addCustomEventListener('lex::intent', this._onIntent.bind(this, ep, cs));
this.ep.addCustomEventListener('lex::transcription', this._onTranscription.bind(this, ep, cs));
this.ep.addCustomEventListener('lex::audio_provided', this._onAudioProvided.bind(this, ep, cs));
this.ep.addCustomEventListener('lex::text_response', this._onTextResponse.bind(this, ep, cs));
this.ep.addCustomEventListener('lex::playback_interruption', this._onPlaybackInterruption.bind(this, ep, cs));
this.ep.addCustomEventListener('lex::error', this._onError.bind(this, ep, cs));
this.ep.on('dtmf', this._onDtmf.bind(this, ep, cs));
const channelVars = {};
if (this.bargein) {
Object.assign(channelVars, {'x-amz-lex:barge-in-enabled': 1});
}
if (this.noInputTimeout) {
Object.assign(channelVars, {'x-amz-lex:audio:start-timeout-ms': this.noInputTimeout});
}
if (this.awsAccessKeyId && this.awsSecretAccessKey) {
Object.assign(channelVars, {
AWS_ACCESS_KEY_ID: this.awsAccessKeyId,
AWS_SECRET_ACCESS_KEY: this.awsSecretAccessKey
});
}
if (this.vendor) Object.assign(channelVars, {LEX_USE_TTS: 1});
//if (this.intent.name) Object.assign(channelVars, {LEX_WELCOME_INTENT: this.intent});
if (this.welcomeMessage && this.welcomeMessage.length) {
Object.assign(channelVars, {LEX_WELCOME_MESSAGE: this.welcomeMessage});
}
if (Object.keys(channelVars).length) await this.ep.set(channelVars);
} catch (err) {
this.logger.error({err}, 'Error setting listeners');
throw err;
}
}
/**
* An intent has been returned.
* we may get an empty intent, signified by ...
* In such a case, we just restart the bot.
* @param {*} ep - media server endpoint
* @param {*} evt - event data
*/
_onIntent(ep, cs, evt) {
this.logger.debug({evt}, `got intent for ${this.botName}`);
if (this.events.includes('intent')) {
this._performHook(cs, this.eventHook, {event: 'intent', data: evt});
}
}
/**
* A transcription - either interim or final - has been returned.
* If we are doing barge-in based on hotword detection, check for the hotword or phrase.
* If we are playing a filler sound, like typing, during the fullfillment phase, start that
* if this is a final transcript.
* @param {*} ep - media server endpoint
* @param {*} evt - event data
*/
_onTranscription(ep, cs, evt) {
this.logger.debug({evt}, `got transcription for ${this.botName}`);
if (this.events.includes('transcription')) {
this._performHook(cs, this.eventHook, {event: 'transcription', data: evt});
}
}
/**
* @param {*} evt - event data
*/
async _onTextResponse(ep, cs, evt) {
this.logger.debug({evt}, `got text response for ${this.botName}`);
const messages = evt.messages;
if (this.events.includes('response-text')) {
this._performHook(cs, this.eventHook, {event: 'response-text', data: evt});
}
if (this.vendor && Array.isArray(messages) && messages.length) {
const msg = messages[0].msg;
const type = messages[0].type;
if (['PlainText', 'SSML'].includes(type) && msg) {
const {srf} = cs;
const {synthAudio} = srf.locals.dbHelpers;
try {
this.logger.debug(`tts with ${this.vendor} ${this.voice}`);
const {filepath} = await synthAudio({
text: msg,
vendor: this.vendor,
language: this.language,
voice: this.voice,
salt: cs.callSid
});
if (filepath) cs.trackTmpFile(filepath);
if (this.events.includes('start-play')) {
this._performHook(cs, this.eventHook, {event: 'start-play', data: {path: filepath}});
}
await ep.play(filepath);
if (this.events.includes('stop-play')) {
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: filepath}});
}
this.logger.debug(`finished tts, sending play_done ${this.vendor} ${this.voice}`);
this.ep.api('aws_lex_play_done', this.ep.uuid)
.catch((err) => {
this.logger.error({err}, `Error sending play_done ${this.botName}`);
});
} catch (err) {
this.logger.error({err}, 'Lex:_onTextResponse - error playing tts');
}
}
}
}
/**
* @param {*} evt - event data
*/
_onPlaybackInterruption(ep, cs, evt) {
this.logger.debug({evt}, `got playback interruption for ${this.botName}`);
if (this.bargein) {
if (this.events.includes('play-interrupted')) {
this._performHook(cs, this.eventHook, {event: 'play-interrupted', data: {}});
}
this.ep.api('uuid_break', this.ep.uuid)
.catch((err) => this.logger.info(err, 'Lex::_onPlaybackInterruption - Error killing audio'));
}
}
/**
* Lex has returned an error of some kind.
* @param {*} evt - event data
*/
_onError(ep, cs, evt) {
this.logger.error({evt}, `got error for bot ${this.botName}`);
}
/**
* Audio has been received from lex and written to a temporary disk file.
* Start playing the audio, after killing any filler sound that might be playing.
* When the audio completes, start the no-input timer.
* @param {*} ep - media server endpoint
* @param {*} evt - event data
*/
async _onAudioProvided(ep, cs, evt) {
if (this.vendor) return;
this.waitingForPlayStart = false;
this.logger.debug({evt}, `got audio file for bot ${this.botName}`);
try {
if (this.events.includes('start-play')) {
this._performHook(cs, this.eventHook, {event: 'start-play', data: {path: evt.path}});
}
await ep.play(evt.path);
if (this.events.includes('stop-play')) {
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: evt.path}});
}
this.logger.debug({evt}, `done playing audio file for bot ${this.botName}`);
this.ep.api('aws_lex_play_done', this.ep.uuid)
.catch((err) => {
this.logger.error({err}, `Error sending play_done ${this.botName}`);
});
} catch (err) {
this.logger.error({err}, `Error playing file ${evt.path} for both ${this.botName}`);
}
}
/**
* receive a dmtf entry from the caller.
* If we have active dtmf instructions, collect and process accordingly.
*/
_onDtmf(ep, cs, evt) {
this.logger.debug({evt}, 'Lex:_onDtmf');
if (this.events.includes('dtmf')) {
this._performHook(cs, this.eventHook, {event: 'dtmf', data: evt});
}
if (this.passDtmf) {
this.ep.api('aws_lex_dtmf', `${this.ep.uuid} ${evt.dtmf}`)
.catch((err) => {
this.logger.error({err}, `Error sending dtmf ${evt.dtmf} ${this.botName}`);
});
}
}
async _performHook(cs, hook, results) {
const json = await this.cs.requestor.request(hook, results);
if (json && Array.isArray(json)) {
const makeTask = require('./make_task');
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
if (tasks && tasks.length > 0) {
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
this.performAction({lexResult: 'redirect'}, false);
cs.replaceApplication(tasks);
}
}
}
}
module.exports = Lex;

View File

@@ -159,6 +159,24 @@ class TaskListen extends Task {
this.notifyTaskDone();
}
/**
* play or say something during the call
* @param {*} tasks - array of play/say tasks to execute
*/
async whisper(tasks, callSid) {
try {
const cs = this.callSession;
this.logger.debug('Listen:whisper tasks starting');
while (tasks.length && !cs.callGone) {
const task = tasks.shift();
await task.exec(cs, this.ep);
}
this.logger.debug('Listen:whisper tasks complete');
} catch (err) {
this.logger.error(err, 'Listen:whisper error');
}
}
}
module.exports = TaskListen;

View File

@@ -30,6 +30,9 @@ function makeTask(logger, obj, parent) {
case TaskName.Dequeue:
const TaskDequeue = require('./dequeue');
return new TaskDequeue(logger, data, parent);
case TaskName.Dtmf:
const TaskDtmf = require('./dtmf');
return new TaskDtmf(logger, data, parent);
case TaskName.Enqueue:
const TaskEnqueue = require('./enqueue');
return new TaskEnqueue(logger, data, parent);
@@ -39,6 +42,12 @@ function makeTask(logger, obj, parent) {
case TaskName.Leave:
const TaskLeave = require('./leave');
return new TaskLeave(logger, data, parent);
case TaskName.Lex:
const TaskLex = require('./lex');
return new TaskLex(logger, data, parent);
case TaskName.Message:
const TaskMessage = require('./message');
return new TaskMessage(logger, data, parent);
case TaskName.Say:
const TaskSay = require('./say');
return new TaskSay(logger, data, parent);

57
lib/tasks/message.js Normal file
View File

@@ -0,0 +1,57 @@
const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants');
const bent = require('bent');
class TaskMessage extends Task {
constructor(logger, opts) {
super(logger, opts);
this.preconditions = TaskPreconditions.None;
this.payload = {
provider: this.data.provider,
to: this.data.to,
from: this.data.from,
cc: this.data.cc,
text: this.data.text,
media: this.data.media
};
}
get name() { return TaskName.Message; }
/**
* Send outbound SMS
*/
async exec(cs, dlg) {
const {srf} = cs;
await super.exec(cs);
try {
const {getSBC} = srf.locals;
const sbcAddress = getSBC();
if (sbcAddress) {
const url = `http://${sbcAddress}:3000/`;
const post = bent(url, 'POST', 'json', 200);
this.logger.info({payload: this.payload, sbcAddress}, 'Message:exec sending outbound SMS');
const response = await post('v1/outboundSMS', this.payload);
this.logger.info({response}, 'Successfully sent SMS');
if (cs.callInfo.res) {
this.logger.info('Message:exec sending 200 OK response to HTTP POST from api server');
cs.callInfo.res.status(200).json({
sid: cs.callInfo.messageSid,
providerResponse: response
});
}
// TODO: action Hook
}
else {
this.logger.info('Message:exec - unable to send SMS as there are no available SBCs');
}
} catch (err) {
this.logger.error(err, 'TaskMessage:exec - Error sending SMS');
}
}
}
module.exports = TaskMessage;

View File

@@ -21,26 +21,28 @@ class TaskSay extends Task {
this.ep = ep;
try {
// synthesize all of the text elements
const filepath = (await Promise.all(this.text.map(async(text) => {
const fp = await synthAudio({
const files = (await Promise.all(this.text.map(async(text) => {
const {filePath} = await synthAudio({
text,
vendor: this.synthesizer.vendor || cs.speechSynthesisVendor,
language: this.synthesizer.language || cs.speechSynthesisLanguage,
voice: this.synthesizer.voice || cs.speechSynthesisVoice,
salt: cs.callSid
}).catch((err) => this.logger.error(err, 'Error synthesizing text'));
if (fp) cs.trackTmpFile(fp);
return fp;
if (filePath) cs.trackTmpFile(filePath);
return filePath;
})))
.filter((fp) => fp && fp.length);
this.logger.debug({filepath}, 'synthesized files for tts');
this.logger.debug({files, loop: this.loop}, 'synthesized files for tts');
if (!this.ep.connected) this.logger.debug('say: endpoint is not connected!');
while (!this.killed && this.loop-- && this.ep.connected) {
let segment = 0;
do {
await ep.play(filepath[segment]);
} while (!this.killed && ++segment < filepath.length);
this.logger.debug(`playing file ${files[segment]}`);
await ep.play(files[segment]);
} while (!this.killed && ++segment < files.length);
}
} catch (err) {
this.logger.info(err, 'TaskSay:exec error');

View File

@@ -1,5 +1,5 @@
const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants');
const {TaskName, TaskPreconditions, CallStatus} = require('../utils/constants');
/**
* Rejects an incoming call with user-specified status code and reason
@@ -19,6 +19,7 @@ class TaskSipDecline extends Task {
res.send(this.data.status, this.data.reason, {
headers: this.headers
});
cs.emit('callStatusChange', {callStatus: CallStatus.Failed, sipStatus: this.data.status});
}
}

View File

@@ -92,7 +92,8 @@
"waitHook": "object|string",
"statusEvents": "array",
"statusHook": "object|string",
"enterHook": "object|string"
"enterHook": "object|string",
"_": "object"
},
"required": [
"name"
@@ -123,6 +124,7 @@
"properties": {
"credentials": "object|string",
"project": "string",
"environment": "string",
"lang": "string",
"actionHook": "object|string",
"eventHook": "object|string",
@@ -132,7 +134,9 @@
"noInputTimeout": "number",
"noInputEvent": "string",
"passDtmfAsTextInput": "boolean",
"thinkingMusic": "string"
"thinkingMusic": "string",
"tts": "#synthesizer",
"bargein": "boolean"
},
"required": [
"project",
@@ -140,6 +144,43 @@
"lang"
]
},
"dtmf": {
"properties": {
"dtmf": "string",
"duration": "number"
},
"required": [
"dtmf"
]
},
"lex": {
"properties": {
"botId": "string",
"botAlias": "string",
"credentials": "object",
"region": "string",
"locale": "string",
"intent": "#lexIntent",
"welcomeMessage": "string",
"metadata": "object",
"bargein": "boolean",
"passDtmf": "boolean",
"actionHook": "object|string",
"eventHook": "object|string",
"prompt": {
"type": "string",
"enum": ["lex", "tts"]
},
"noInputTimeout": "number",
"tts": "#synthesizer"
},
"required": [
"botId",
"botAlias",
"region",
"prompt"
]
},
"listen": {
"properties": {
"actionHook": "object|string",
@@ -164,6 +205,20 @@
"url"
]
},
"message": {
"properties": {
"provider": "string",
"to": "string",
"from": "string",
"text": "string",
"media": "string|array",
"actionHook": "object|string"
},
"required": [
"to",
"from"
]
},
"pause": {
"properties": {
"length": "number"
@@ -218,7 +273,8 @@
"earlyMedia": "boolean"
},
"required": [
"transcriptionHook"
"transcriptionHook",
"recognizer"
]
},
"target": {
@@ -237,7 +293,8 @@
"sipUri": "string",
"auth": "#auth",
"vmail": "boolean",
"tenant": "string"
"tenant": "string",
"overrideTo": "string"
},
"required": [
"type"
@@ -257,7 +314,7 @@
"properties": {
"vendor": {
"type": "string",
"enum": ["google", "aws", "polly"]
"enum": ["google", "aws", "polly", "default"]
},
"language": "string",
"voice": "string",
@@ -274,16 +331,59 @@
"properties": {
"vendor": {
"type": "string",
"enum": ["google"]
"enum": ["google", "aws", "default"]
},
"language": "string",
"hints": "array",
"altLanguages": "array",
"profanityFilter": "boolean",
"interim": "boolean",
"dualChannel": "boolean"
"singleUtterance": "boolean",
"dualChannel": "boolean",
"separateRecognitionPerChannel": "boolean",
"punctuation": "boolean",
"enhancedModel": "boolean",
"words": "boolean",
"diarization": "boolean",
"diarizationMinSpeakers": "number",
"diarizationMaxSpeakers": "number",
"interactionType": {
"type": "string",
"enum": [
"unspecified",
"discussion",
"presentation",
"phone_call",
"voicemail",
"voice_search",
"voice_command",
"dictation"
]
},
"naicsCode": "number",
"identifyChannels": "boolean",
"vocabularyName": "string",
"vocabularyFilterName": "string",
"filterMethod": {
"type": "string",
"enum": [
"remove",
"mask",
"tag"
]
}
},
"required": [
"vendor"
]
},
"lexIntent": {
"properties": {
"name": "string",
"slots": "object"
},
"required": [
"name"
]
}
}

View File

@@ -1,5 +1,5 @@
const Emitter = require('events');
const uuidv4 = require('uuid/v4');
const { v4: uuidv4 } = require('uuid');
const debug = require('debug')('jambonz:feature-server');
const assert = require('assert');
const {TaskPreconditions} = require('../utils/constants');
@@ -106,7 +106,7 @@ class Task extends Emitter {
delete obj.requestor;
delete obj.notifier;
obj.tasks = cs.getRemainingTaskData();
if (opts && obj.tasks.length > 1) {
if (opts && obj.tasks.length > 0) {
const key = Object.keys(obj.tasks[0])[0];
Object.assign(obj.tasks[0][key], {_: opts});
}

View File

@@ -1,5 +1,10 @@
const Task = require('./task');
const {TaskName, TaskPreconditions, TranscriptionEvents} = require('../utils/constants');
const {
TaskName,
TaskPreconditions,
GoogleTranscriptionEvents,
AwsTranscriptionEvents
} = require('../utils/constants');
class TaskTranscribe extends Task {
constructor(logger, opts, parentTask) {
@@ -8,12 +13,31 @@ class TaskTranscribe extends Task {
this.transcriptionHook = this.data.transcriptionHook;
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
if (this.data.recognizer) {
this.language = this.data.recognizer.language || 'en-US';
this.vendor = this.data.recognizer.vendor;
this.interim = this.data.recognizer.interim === true;
this.dualChannel = this.data.recognizer.dualChannel === true;
}
const recognizer = this.data.recognizer;
this.vendor = recognizer.vendor;
this.language = recognizer.language;
this.interim = !!recognizer.interim;
this.separateRecognitionPerChannel = recognizer.separateRecognitionPerChannel;
/* google-specific options */
this.hints = recognizer.hints || [];
this.profanityFilter = recognizer.profanityFilter;
this.punctuation = !!recognizer.punctuation;
this.enhancedModel = !!recognizer.enhancedModel;
this.words = !!recognizer.words;
this.diarization = !!recognizer.diarization;
this.diarizationMinSpeakers = recognizer.diarizationMinSpeakers || 0;
this.diarizationMaxSpeakers = recognizer.diarizationMaxSpeakers || 0;
this.interactionType = recognizer.interactionType || 'unspecified';
this.naicsCode = recognizer.naicsCode || 0;
this.altLanguages = recognizer.altLanguages || [];
/* aws-specific options */
this.identifyChannels = !!recognizer.identifyChannels;
this.vocabularyName = recognizer.vocabularyName;
this.vocabularyFilterName = recognizer.vocabularyFilterName;
this.filterMethod = recognizer.filterMethod;
}
get name() { return TaskName.Transcribe; }
@@ -21,21 +45,27 @@ class TaskTranscribe extends Task {
async exec(cs, ep, parentTask) {
super.exec(cs);
this.ep = ep;
if ('default' === this.vendor || !this.vendor) this.vendor = cs.speechRecognizerVendor;
if ('default' === this.language || !this.language) this.language = cs.speechRecognizerLanguage;
try {
await this._startTranscribing(ep);
await this.awaitTaskDone();
} catch (err) {
this.logger.info(err, 'TaskTranscribe:exec - error');
}
ep.removeCustomEventListener(TranscriptionEvents.Transcription);
ep.removeCustomEventListener(TranscriptionEvents.NoAudioDetected);
ep.removeCustomEventListener(TranscriptionEvents.MaxDurationExceeded);
ep.removeCustomEventListener(GoogleTranscriptionEvents.Transcription);
ep.removeCustomEventListener(GoogleTranscriptionEvents.NoAudioDetected);
ep.removeCustomEventListener(GoogleTranscriptionEvents.MaxDurationExceeded);
ep.removeCustomEventListener(AwsTranscriptionEvents.Transcription);
ep.removeCustomEventListener(AwsTranscriptionEvents.NoAudioDetected);
ep.removeCustomEventListener(AwsTranscriptionEvents.MaxDurationExceeded);
}
async kill(cs) {
super.kill(cs);
if (this.ep.connected) {
this.ep.stopTranscription().catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
this.ep.stopTranscription({vendor: this.vendor})
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
// hangup after 1 sec if we don't get a final transcription
this._timer = setTimeout(() => this.notifyTaskDone(), 1000);
@@ -45,34 +75,83 @@ class TaskTranscribe extends Task {
}
async _startTranscribing(ep) {
const opts = {
GOOGLE_SPEECH_USE_ENHANCED: true,
GOOGLE_SPEECH_MODEL: 'phone_call'
};
if (this.hints) {
Object.assign(opts, {'GOOGLE_SPEECH_HINTS': this.hints.join(',')});
}
if (this.profanityFilter) {
Object.assign(opts, {'GOOGLE_SPEECH_PROFANITY_FILTER': true});
}
if (this.dualChannel) {
Object.assign(opts, {'GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL': true});
}
await ep.set(opts)
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing'));
const opts = {};
ep.addCustomEventListener(TranscriptionEvents.Transcription, this._onTranscription.bind(this, ep));
ep.addCustomEventListener(TranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, ep));
ep.addCustomEventListener(TranscriptionEvents.MaxDurationExceeded, this._onMaxDurationExceeded.bind(this, ep));
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, this._onTranscription.bind(this, ep));
ep.addCustomEventListener(GoogleTranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, ep));
ep.addCustomEventListener(GoogleTranscriptionEvents.MaxDurationExceeded,
this._onMaxDurationExceeded.bind(this, ep));
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, this._onTranscription.bind(this, ep));
ep.addCustomEventListener(AwsTranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, ep));
ep.addCustomEventListener(AwsTranscriptionEvents.MaxDurationExceeded,
this._onMaxDurationExceeded.bind(this, ep));
if (this.vendor === 'google') {
[
['enhancedModel', 'GOOGLE_SPEECH_USE_ENHANCED'],
['separateRecognitionPerChannel', 'GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL'],
['profanityFilter', 'GOOGLE_SPEECH_PROFANITY_FILTER'],
['punctuation', 'GOOGLE_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION'],
['words', 'GOOGLE_SPEECH_ENABLE_WORD_TIME_OFFSETS'],
['diarization', 'GOOGLE_SPEECH_PROFANITY_FILTER']
].forEach((arr) => {
if (this[arr[0]]) opts[arr[1]] = true;
});
if (this.hints.length > 1) opts.GOOGLE_SPEECH_HINTS = this.hints.join(',');
if (this.altLanguages.length > 1) opts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
if ('unspecified' !== this.interactionType) {
opts.GOOGLE_SPEECH_METADATA_INTERACTION_TYPE = this.interactionType;
// additionally set model if appropriate
if ('phone_call' === this.interactionType) opts.GOOGLE_SPEECH_MODEL = 'phone_call';
else if (['voice_search', 'voice_command'].includes(this.interactionType)) {
opts.GOOGLE_SPEECH_MODEL = 'command_and_search';
}
else opts.GOOGLE_SPEECH_MODEL = 'phone_call';
}
else opts.GOOGLE_SPEECH_MODEL = 'phone_call';
if (this.diarization && this.diarizationMinSpeakers > 0) {
opts.GOOGLE_SPEECH_SPEAKER_DIARIZATION_MIN_SPEAKER_COUNT = this.diarizationMinSpeakers;
}
if (this.diarization && this.diarizationMaxSpeakers > 0) {
opts.GOOGLE_SPEECH_SPEAKER_DIARIZATION_MAX_SPEAKER_COUNT = this.diarizationMaxSpeakers;
}
if (this.naicsCode > 0) opts.GOOGLE_SPEECH_METADATA_INDUSTRY_NAICS_CODE = this.naicsCode;
await ep.set(opts)
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with google'));
}
else if (this.vendor === 'aws') {
[
['diarization', 'AWS_SHOW_SPEAKER_LABEL'],
['identifyChannels', 'AWS_ENABLE_CHANNEL_IDENTIFICATION']
].forEach((arr) => {
if (this[arr[0]]) opts[arr[1]] = true;
});
if (this.vocabularyName) opts.AWS_VOCABULARY_NAME = this.vocabularyName;
if (this.vocabularyFilterName) {
opts.AWS_VOCABULARY_NAME = this.vocabularyFilterName;
opts.AWS_VOCABULARY_FILTER_METHOD = this.filterMethod || 'mask';
}
Object.assign(opts, {
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
AWS_REGION: process.env.AWS_REGION
});
await ep.set(opts)
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with aws'));
}
await this._transcribe(ep);
}
async _transcribe(ep) {
await this.ep.startTranscription({
await ep.startTranscription({
vendor: this.vendor,
interim: this.interim ? true : false,
language: this.language || this.callSession.speechRecognizerLanguage,
channels: this.dualChannel ? 2 : 1
locale: this.language,
channels: this.separateRecognitionPerChannel ? 2 : 1
});
}

View File

@@ -4,11 +4,14 @@
"Dequeue": "dequeue",
"Dial": "dial",
"Dialogflow": "dialogflow",
"Dtmf": "dtmf",
"Enqueue": "enqueue",
"Gather": "gather",
"Hangup": "hangup",
"Leave": "leave",
"Lex": "lex",
"Listen": "listen",
"Message": "message",
"Pause": "pause",
"Play": "play",
"Redirect": "redirect",
@@ -34,7 +37,8 @@
},
"CallDirection": {
"Inbound": "inbound",
"Outbound": "outbound"
"Outbound": "outbound",
"None": "none"
},
"ListenStatus": {
"Pause": "pause",
@@ -47,12 +51,18 @@
"StableCall": "stable-call",
"UnansweredCall": "unanswered-call"
},
"TranscriptionEvents": {
"GoogleTranscriptionEvents": {
"Transcription": "google_transcribe::transcription",
"EndOfUtterance": "google_transcribe::end_of_utterance",
"NoAudioDetected": "google_transcribe::no_audio_detected",
"MaxDurationExceeded": "google_transcribe::max_duration_exceeded"
},
"AwsTranscriptionEvents": {
"Transcription": "aws_transcribe::transcription",
"EndOfTranscript": "aws_transcribe::end_of_transcript",
"NoAudioDetected": "aws_transcribe::no_audio_detected",
"MaxDurationExceeded": "aws_transcribe::max_duration_exceeded"
},
"ListenEvents": {
"Connect": "mod_audio_fork::connect",
"ConnectFailure": "mod_audio_fork::connect_failed",
@@ -82,6 +92,10 @@
"Hangup": "hangup",
"Timeout": "timeout"
},
"KillReason": {
"Hangup": "hangup",
"Replaced": "replaced"
},
"MAX_SIMRINGS": 10,
"BONG_TONE": "tone_stream://v=-7;%(100,0,941.0,1477.0);v=-7;>=2;+=.1;%(1400,0,350,440)"
}

View File

@@ -99,11 +99,13 @@ function installSrfLocals(srf, logger) {
}
const {
pool,
lookupAppByPhoneNumber,
lookupAppBySid,
lookupAppByRealm,
lookupAppByTeamsTenant,
lookupTeamsByAccount
lookupTeamsByAccount,
lookupAccountBySid
} = require('@jambonz/db-helpers')({
host: process.env.JAMBONES_MYSQL_HOST,
user: process.env.JAMBONES_MYSQL_USER,
@@ -137,11 +139,13 @@ function installSrfLocals(srf, logger) {
Object.assign(srf.locals, {
dbHelpers: {
pool,
lookupAppByPhoneNumber,
lookupAppBySid,
lookupAppByRealm,
lookupAppByTeamsTenant,
lookupTeamsByAccount,
lookupAccountBySid,
updateCallStatus,
retrieveCall,
listCalls,

View File

@@ -6,14 +6,15 @@ const CallInfo = require('../session/call-info');
const assert = require('assert');
const ConfirmCallSession = require('../session/confirm-call-session');
const selectSbc = require('./select-sbc');
const Registrar = require('jambonz-mw-registrar');
const Registrar = require('@jambonz/mw-registrar');
const AdultingCallSession = require('../session/adulting-call-session');
const registrar = new Registrar({
host: process.env.JAMBONES_REDIS_HOST,
port: process.env.JAMBONES_REDIS_PORT || 6379
});
const deepcopy = require('deepcopy');
const moment = require('moment');
const uuidv4 = require('uuid/v4');
const { v4: uuidv4 } = require('uuid');
class SingleDialer extends Emitter {
constructor({logger, sbcAddress, target, opts, application, callInfo}) {
@@ -59,6 +60,7 @@ class SingleDialer extends Emitter {
async exec(srf, ms, opts) {
opts = opts || {};
opts.headers = opts.headers || {};
let uri, to;
try {
switch (this.target.type) {
@@ -69,7 +71,6 @@ class SingleDialer extends Emitter {
to = this.target.number;
if ('teams' === this.target.type) {
assert(this.target.teamsInfo);
opts.headers = opts.headers || {};
Object.assign(opts.headers, {
'X-MS-Teams-FQDN': this.target.teamsInfo.ms_teams_fqdn,
'X-MS-Teams-Tenant-FQDN': this.target.teamsInfo.tenant_fqdn
@@ -83,6 +84,12 @@ class SingleDialer extends Emitter {
uri = `sip:${this.target.name}`;
to = this.target.name;
if (this.target.overrideTo) {
Object.assign(opts.headers, {
'X-Override-To': this.target.overrideTo
});
}
// need to send to the SBC registered on
const reg = await registrar.query(aor);
if (reg) {
@@ -108,11 +115,22 @@ class SingleDialer extends Emitter {
this.ep = await ms.createEndpoint();
this.logger.debug(`SingleDialer:exec - created endpoint ${this.ep.uuid}`);
let promiseStreamConnected;
/**
* were we killed whilst we were off getting an endpoint ?
* https://github.com/jambonz/jambonz-feature-server/issues/30
*/
if (this.killed) {
this.logger.info('SingleDialer:exec got quick CANCEL from caller, abort outdial');
this.ep.destroy()
.catch((err) => this.logger.error({err}, 'Error destroying endpoint'));
return;
}
let lastSdp;
const connectStream = async(remoteSdp) => {
// wait for previous re-invite to complete, if any
if (promiseStreamConnected) await promiseStreamConnected.catch((err) => {});
return promiseStreamConnected = this.ep.modify(remoteSdp);
if (remoteSdp === lastSdp) return;
lastSdp = remoteSdp;
return this.ep.modify(remoteSdp);
};
Object.assign(opts, {
@@ -120,7 +138,7 @@ class SingleDialer extends Emitter {
localSdp: this.ep.local.sdp
});
if (this.target.auth) opts.auth = this.target.auth;
this.dlg = await srf.createUAC(uri, opts, {
this.dlg = await srf.createUAC(uri, {...opts, followRedirects: true, keepUriOnRedirect: true}, {
cbRequest: (err, req) => {
if (err) {
this.logger.error(err, 'SingleDialer:exec Error creating call');
@@ -168,6 +186,15 @@ class SingleDialer extends Emitter {
this.logger.debug(`SingleDialer:exec call connected: ${this.callSid}`);
const connectTime = this.dlg.connectTime = moment();
/* race condition: we were killed just as call was answered */
if (this.killed) {
this.logger.info(`SingleDialer:exec race condition - we were killed as call connected: ${this.callSid}`);
const duration = moment().diff(connectTime, 'seconds');
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
if (this.ep) this.ep.destroy();
return;
}
this.dlg
.on('destroy', () => {
const duration = moment().diff(connectTime, 'seconds');
@@ -209,6 +236,7 @@ class SingleDialer extends Emitter {
* kill the call in progress or the stable dialog, whichever we have
*/
async kill() {
this.killed = true;
if (this.inviteInProgress) await this.inviteInProgress.cancel();
else if (this.dlg && this.dlg.connected) {
const duration = moment().diff(this.dlg.connectTime, 'seconds');
@@ -248,7 +276,7 @@ class SingleDialer extends Emitter {
// now execute it in a new ConfirmCallSession
this.logger.debug(`SingleDialer:_executeApp: executing ${tasks.length} tasks`);
const cs = new ConfirmCallSession({
logger: this.logger,
logger: this.baseLogger,
application: this.application,
dlg: this.dlg,
ep: this.ep,
@@ -266,6 +294,24 @@ class SingleDialer extends Emitter {
}
}
async doAdulting({logger, tasks, application}) {
this.logger = logger;
this.adulting = true;
this.emit('adulting');
await this.ep.unbridge()
.catch((err) => this.logger.info({err}, 'SingleDialer:doAdulting - failed to unbridge ep'));
this.ep.play('silence_stream://1000');
const cs = new AdultingCallSession({
logger: this.logger,
singleDialer: this,
application,
callInfo: this.callInfo,
tasks
});
cs.exec();
return cs;
}
_notifyCallStatusChange({callStatus, sipStatus, duration}) {
assert((typeof duration === 'number' && callStatus === CallStatus.Completed) ||
(!duration && callStatus !== CallStatus.Completed),

View File

@@ -1,7 +1,7 @@
const bent = require('bent');
const parseUrl = require('parse-url');
const assert = require('assert');
const snakeCaseKeys = require('./snakecase-keys');
const toBase64 = (str) => Buffer.from(str || '', 'utf8').toString('base64');
function basicAuth(username, password) {
@@ -62,7 +62,7 @@ class Requestor {
* @param {object} [params] - request parameters
*/
async request(hook, params) {
params = params || null;
const payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null;
const url = hook.url || hook;
const method = hook.method || 'POST';
const {username, password} = typeof hook === 'object' ? hook : {};
@@ -70,16 +70,16 @@ class Requestor {
assert.ok(url, 'Requestor:request url was not provided');
assert.ok, (['GET', 'POST'].includes(method), `Requestor:request method must be 'GET' or 'POST' not ${method}`);
this.logger.debug({hook, params}, `Requestor:request ${method} ${url}`);
this.logger.debug({hook, payload}, `Requestor:request ${method} ${url}`);
const startAt = process.hrtime();
let buf;
try {
buf = isRelativeUrl(url) ?
await this.post(url, params, this.authHeader) :
await bent(method, 'buffer', 200, 201, 202)(url, params, basicAuth(username, password));
await this.post(url, payload, this.authHeader) :
await bent(method, 'buffer', 200, 201, 202)(url, payload, basicAuth(username, password));
} catch (err) {
this.logger.info({baseUrl: this.baseUrl, url: err.statusCode},
this.logger.info({baseUrl: this.baseUrl, url, statusCode: err.statusCode},
`web callback returned unexpected error code ${err.statusCode}`);
throw err;
}

View File

@@ -18,8 +18,7 @@ module.exports = (logger) => {
// listen for SNS lifecycle changes
let lifecycleEmitter = new Emitter();
let dryUpCalls = false;
if (process.env.AWS_SNS_TOPIC_ARM &&
process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY && process.env.AWS_REGION) {
if (process.env.AWS_SNS_TOPIC_ARM && process.env.AWS_REGION) {
(async function() {
try {

View File

@@ -0,0 +1,25 @@
const snakeCase = require('to-snake-case');
const isObject = (value) => typeof value === 'object' && value !== null;
const snakeObject = (obj, excludes) => {
if (Array.isArray(obj)) return obj.map((o) => {
return isObject(o) ? snakeObject(o, excludes) : o;
});
const target = {};
for (const [key, value] of Object.entries(obj)) {
if (excludes.includes(key)) {
target[key] = value;
continue;
}
const newKey = snakeCase(key);
const newValue = isObject(value) ? snakeObject(value, excludes) : value;
target[newKey] = newValue;
}
return target;
};
module.exports = (obj, excludes = []) => {
return snakeObject(obj, excludes);
};

7125
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "jambonz-feature-server",
"version": "0.2.4",
"version": "0.3.1",
"main": "app.js",
"engines": {
"node": ">= 10.16.0"
@@ -26,30 +26,36 @@
"jslint": "eslint app.js lib"
},
"dependencies": {
"@jambonz/db-helpers": "^0.4.2",
"@jambonz/realtimedb-helpers": "^0.2.16",
"@jambonz/stats-collector": "^0.0.4",
"bent": "^7.3.9",
"@jambonz/db-helpers": "^0.5.20",
"@jambonz/mw-registrar": "^0.1.9",
"@jambonz/realtimedb-helpers": "^0.4.1",
"@jambonz/stats-collector": "^0.1.5",
"@jambonz/time-series": "^0.1.5",
"aws-sdk": "^2.848.0",
"bent": "^7.3.12",
"cidr-matcher": "^2.1.1",
"debug": "^4.1.1",
"debug": "^4.3.1",
"deepcopy": "^2.1.0",
"drachtio-fsmrf": "^2.0.1",
"drachtio-srf": "^4.4.37",
"drachtio-fsmrf": "^2.0.7",
"drachtio-srf": "^4.4.50",
"express": "^4.17.1",
"ip": "^1.1.5",
"jambonz-mw-registrar": "^0.1.3",
"moment": "^2.27.0",
"moment": "^2.29.1",
"parse-url": "^5.0.2",
"pino": "^6.5.1",
"pino": "^6.11.1",
"to-snake-case": "^1.0.0",
"uuid": "^8.3.2",
"verify-aws-sns-signature": "^0.0.6",
"xml2js": "^0.4.23"
},
"devDependencies": {
"async": "^3.2.0",
"blue-tape": "^1.0.0",
"clear-module": "^4.1.1",
"eslint": "^7.7.0",
"eslint-plugin-promise": "^4.2.1",
"eslint": "^7.20.0",
"eslint-plugin-promise": "^4.3.1",
"nyc": "^15.1.0",
"tap-spec": "^5.0.0"
"tap-spec": "^5.0.0",
"tape": "^5.2.0"
}
}