mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2026-03-30 03:49:10 +00:00
Compare commits
53 Commits
v0.4.3
...
v0.5-branc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9b70debc7 | ||
|
|
beb9c8c81d | ||
|
|
90ed866404 | ||
|
|
4d5876a6a0 | ||
|
|
2cd3e6e724 | ||
|
|
0df937fb85 | ||
|
|
3226cae04b | ||
|
|
29440a1c9d | ||
|
|
2b782bc5f3 | ||
|
|
9bd406fcba | ||
|
|
28df651c44 | ||
|
|
05ae2c6039 | ||
|
|
dc92f75529 | ||
|
|
c0a29c7a64 | ||
|
|
08c53aa586 | ||
|
|
bb0ec8e184 | ||
|
|
473a34ec9f | ||
|
|
686cf1b094 | ||
|
|
5cc4852bf9 | ||
|
|
576f645489 | ||
|
|
8eb0cd1520 | ||
|
|
e441c5be36 | ||
|
|
dd48b5c9da | ||
|
|
c6168ce994 | ||
|
|
70e4e10a70 | ||
|
|
82768a0442 | ||
|
|
8b3ffe911d | ||
|
|
a7e0fb2e8a | ||
|
|
f8e84b5ad0 | ||
|
|
0cff553310 | ||
|
|
873729edb1 | ||
|
|
756db59671 | ||
|
|
59d685319e | ||
|
|
ec7a1858d6 | ||
|
|
63a00063c1 | ||
|
|
2a8f165468 | ||
|
|
d3f8e032d1 | ||
|
|
a1054d2d38 | ||
|
|
fa87a477ac | ||
|
|
69349dab75 | ||
|
|
b679d11fd7 | ||
|
|
ea8609b8c3 | ||
|
|
ef17ed40f7 | ||
|
|
5c5c9d9ae2 | ||
|
|
6e32d82364 | ||
|
|
bfd8355432 | ||
|
|
1a29d48334 | ||
|
|
4d6ef8e334 | ||
|
|
cac259ec1c | ||
|
|
1bc583e805 | ||
|
|
16c728e246 | ||
|
|
25c3512e41 | ||
|
|
5291824501 |
@@ -8,7 +8,7 @@
|
||||
"jsx": false,
|
||||
"modules": false
|
||||
},
|
||||
"ecmaVersion": 2017
|
||||
"ecmaVersion": 2018
|
||||
},
|
||||
"plugins": ["promise"],
|
||||
"rules": {
|
||||
|
||||
19
.github/workflows/build.yml
vendored
Normal file
19
.github/workflows/build.yml
vendored
Normal 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
1
.gitignore
vendored
@@ -39,3 +39,4 @@ examples/*
|
||||
|
||||
ecosystem.config.js
|
||||
.vscode
|
||||
run-tests.sh
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
sudo: required
|
||||
language: node_js
|
||||
node_js:
|
||||
- "lts/*"
|
||||
script:
|
||||
- npm test
|
||||
17
Dockerfile
17
Dockerfile
@@ -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
144
README.md
@@ -1,78 +1,86 @@
|
||||
# jambones-feature-server [](http://travis-ci.org/jambonz/jambones-feature-server)
|
||||
# jambones-feature-server 
|
||||
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
43
lib/session/adulting-call-session.js
Normal file
43
lib/session/adulting-call-session.js
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
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.
|
||||
* It holds the resources, such as the sip dialog and media server endpoint
|
||||
@@ -45,9 +52,11 @@ class CallSession extends Emitter {
|
||||
this.serviceUrl = srf.locals.serviceUrl;
|
||||
}
|
||||
|
||||
if (!this.isConfirmCallSession && !this.isSmsCallSession) {
|
||||
if (!this.isConfirmCallSession && !this.isSmsCallSession && !this.isAdultingCallSession) {
|
||||
sessionTracker.add(this.callSid, this);
|
||||
}
|
||||
|
||||
this._pool = srf.locals.dbHelpers.pool;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,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
|
||||
*/
|
||||
@@ -296,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,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) {
|
||||
@@ -436,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;
|
||||
}
|
||||
}
|
||||
@@ -445,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 = [];
|
||||
@@ -489,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) {
|
||||
@@ -658,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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') {
|
||||
@@ -46,6 +47,7 @@ class Dialogflow extends Task {
|
||||
this.language = this.data.tts.language || 'default';
|
||||
this.voice = this.data.tts.voice || 'default';
|
||||
}
|
||||
this.bargein = this.data.bargein;
|
||||
}
|
||||
|
||||
get name() { return TaskName.Dialogflow; }
|
||||
@@ -59,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}`);
|
||||
@@ -88,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'));
|
||||
@@ -153,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;
|
||||
}
|
||||
@@ -210,23 +214,33 @@ class Dialogflow extends Task {
|
||||
salt: cs.callSid
|
||||
};
|
||||
this.logger.debug({obj}, 'Dialogflow:_onIntent - playing message via tts');
|
||||
const fp = await synthAudio(obj);
|
||||
if (fp) cs.trackTmpFile(fp);
|
||||
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 = fp;
|
||||
this.curentAudioFile = filePath;
|
||||
|
||||
this.logger.debug(`starting to play tts ${filePath}`);
|
||||
|
||||
this.logger.debug(`starting to play tts ${fp}`);
|
||||
if (this.events.includes('start-play')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'start-play', data: {path: fp}});
|
||||
this._performHook(cs, this.eventHook, {event: 'start-play', data: {path: filePath}});
|
||||
}
|
||||
await ep.play(fp);
|
||||
await ep.play(filePath);
|
||||
if (this.events.includes('stop-play')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: fp}});
|
||||
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: filePath}});
|
||||
}
|
||||
this.logger.debug(`finished ${fp}`);
|
||||
this.logger.debug(`finished ${filePath}`);
|
||||
|
||||
if (this.curentAudioFile === fp) {
|
||||
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;
|
||||
|
||||
@@ -253,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) {
|
||||
@@ -268,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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -321,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) {
|
||||
@@ -414,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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ class Lex extends Task {
|
||||
this.alias = this.data.botAlias;
|
||||
this.region = this.data.region;
|
||||
this.locale = this.data.locale || 'en_US';
|
||||
this.intent = this.data.intent;
|
||||
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;
|
||||
@@ -49,10 +50,20 @@ class Lex extends Task {
|
||||
try {
|
||||
await this.init(cs, ep);
|
||||
|
||||
this.logger.debug(`starting lex bot ${this.botName} with locale ${this.locale}`);
|
||||
|
||||
// kick it off
|
||||
this.ep.api('aws_lex_start', `${this.ep.uuid} ${this.bot} ${this.alias} ${this.region} ${this.locale}`)
|
||||
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();
|
||||
@@ -114,7 +125,7 @@ class Lex extends Task {
|
||||
});
|
||||
}
|
||||
if (this.vendor) Object.assign(channelVars, {LEX_USE_TTS: 1});
|
||||
if (this.intent && this.intent.length) Object.assign(channelVars, {LEX_WELCOME_INTENT: this.intent});
|
||||
//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});
|
||||
}
|
||||
@@ -173,20 +184,20 @@ class Lex extends Task {
|
||||
|
||||
try {
|
||||
this.logger.debug(`tts with ${this.vendor} ${this.voice}`);
|
||||
const fp = await synthAudio({
|
||||
const {filepath} = await synthAudio({
|
||||
text: msg,
|
||||
vendor: this.vendor,
|
||||
language: this.language,
|
||||
voice: this.voice,
|
||||
salt: cs.callSid
|
||||
});
|
||||
if (fp) cs.trackTmpFile(fp);
|
||||
if (filepath) cs.trackTmpFile(filepath);
|
||||
if (this.events.includes('start-play')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'start-play', data: {path: fp}});
|
||||
this._performHook(cs, this.eventHook, {event: 'start-play', data: {path: filepath}});
|
||||
}
|
||||
await ep.play(fp);
|
||||
await ep.play(filepath);
|
||||
if (this.events.includes('stop-play')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: fp}});
|
||||
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)
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -133,7 +135,8 @@
|
||||
"noInputEvent": "string",
|
||||
"passDtmfAsTextInput": "boolean",
|
||||
"thinkingMusic": "string",
|
||||
"tts": "#synthesizer"
|
||||
"tts": "#synthesizer",
|
||||
"bargein": "boolean"
|
||||
},
|
||||
"required": [
|
||||
"project",
|
||||
@@ -157,8 +160,9 @@
|
||||
"credentials": "object",
|
||||
"region": "string",
|
||||
"locale": "string",
|
||||
"intent": "string",
|
||||
"intent": "#lexIntent",
|
||||
"welcomeMessage": "string",
|
||||
"metadata": "object",
|
||||
"bargein": "boolean",
|
||||
"passDtmf": "boolean",
|
||||
"actionHook": "object|string",
|
||||
@@ -269,7 +273,8 @@
|
||||
"earlyMedia": "boolean"
|
||||
},
|
||||
"required": [
|
||||
"transcriptionHook"
|
||||
"transcriptionHook",
|
||||
"recognizer"
|
||||
]
|
||||
},
|
||||
"target": {
|
||||
@@ -288,7 +293,8 @@
|
||||
"sipUri": "string",
|
||||
"auth": "#auth",
|
||||
"vmail": "boolean",
|
||||
"tenant": "string"
|
||||
"tenant": "string",
|
||||
"overrideTo": "string"
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
@@ -325,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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -51,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",
|
||||
@@ -86,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)"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
@@ -109,6 +116,16 @@ class SingleDialer extends Emitter {
|
||||
this.ep = await ms.createEndpoint();
|
||||
this.logger.debug(`SingleDialer:exec - created endpoint ${this.ep.uuid}`);
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
if (remoteSdp === lastSdp) return;
|
||||
@@ -121,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');
|
||||
@@ -169,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');
|
||||
@@ -210,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');
|
||||
@@ -249,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,
|
||||
@@ -267,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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
25
lib/utils/snakecase-keys.js
Normal file
25
lib/utils/snakecase-keys.js
Normal 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);
|
||||
};
|
||||
6572
package-lock.json
generated
6572
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@@ -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,32 +26,36 @@
|
||||
"jslint": "eslint app.js lib"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jambonz/db-helpers": "^0.5.1",
|
||||
"@jambonz/realtimedb-helpers": "^0.2.19",
|
||||
"@jambonz/stats-collector": "^0.1.1 ",
|
||||
"@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.2.0",
|
||||
"debug": "^4.3.1",
|
||||
"deepcopy": "^2.1.0",
|
||||
"drachtio-fsmrf": "^2.0.2",
|
||||
"drachtio-srf": "^4.4.39",
|
||||
"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.29.1",
|
||||
"parse-url": "^5.0.2",
|
||||
"pino": "^6.7.0",
|
||||
"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": {
|
||||
"lodash": "4.17.20",
|
||||
"async": "^3.2.0",
|
||||
"blue-tape": "^1.0.0",
|
||||
"clear-module": "^4.1.1",
|
||||
"eslint": "^7.11.0",
|
||||
"eslint-plugin-promise": "^4.2.1",
|
||||
"eslint": "^7.20.0",
|
||||
"eslint-plugin-promise": "^4.3.1",
|
||||
"nyc": "^15.1.0",
|
||||
"tap-dot": "^2.0.0",
|
||||
"tap-spec": "^5.0.0"
|
||||
"tap-spec": "^5.0.0",
|
||||
"tape": "^5.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user