Compare commits

..

102 Commits

Author SHA1 Message Date
Dave Horton
c16a2662f2 bugfix: rest outdial issue caused by req.srf not properly set 2022-02-14 09:14:13 -05:00
Dave Horton
c1130adf03 merge 2022-02-12 10:12:45 -05:00
Dave Horton
f982f6c7d8 update to latest realtimedb-helpers 2022-02-12 10:10:03 -05:00
Snyk bot
f20190b0fc fix: upgrade aws-sdk from 2.1061.0 to 2.1062.0 (#69)
Snyk has created this PR to upgrade aws-sdk from 2.1061.0 to 2.1062.0.

See this package in npm:
https://www.npmjs.com/package/aws-sdk

See this project in Snyk:
https://app.snyk.io/org/davehorton/project/cec90d0e-0ded-433e-a42e-fe78b28ae489?utm_source=github&utm_medium=referral&page=upgrade-pr
2022-02-12 09:23:13 -05:00
Snyk bot
74e85e1b16 fix: upgrade aws-sdk from 2.1060.0 to 2.1061.0 (#68)
Snyk has created this PR to upgrade aws-sdk from 2.1060.0 to 2.1061.0.

See this package in npm:
https://www.npmjs.com/package/aws-sdk

See this project in Snyk:
https://app.snyk.io/org/davehorton/project/cec90d0e-0ded-433e-a42e-fe78b28ae489?utm_source=github&utm_medium=referral&page=upgrade-pr
2022-02-11 07:48:02 -05:00
Dave Horton
63e9cb985e allow target-level headers on outdials (#29) 2022-02-10 14:34:21 -05:00
Dave Horton
2e88ab1f55 bugfix: race condition on hangup sometimes resulted in outbound call attempt even though caller had hung up 2022-02-10 12:15:25 -05:00
Dave Horton
7f75a35515 bugfix: race condition on hangup could cause us to send dup webhook 2022-02-10 11:16:57 -05:00
Dave Horton
941727e93f add fs_public_ip to webhook payload (only when running in ec2 autoscale group) 2022-02-10 09:51:48 -05:00
Dave Horton
d8bfa33a00 include fs_sip_address and api_base_url in webhook paylods 2022-02-10 09:19:33 -05:00
Dave Horton
30ed5b6a02 add support for vad to gather and transcribe (#67) 2022-02-10 08:45:16 -05:00
Dave Horton
bac1b7f2c6 bump version 2022-02-09 15:42:27 -05:00
Dave Horton
48deb3ae89 update to latest @jambonz/realtimedb-helpers with support for redis username / password auth 2022-02-09 15:21:55 -05:00
Dave Horton
de83f735ea memory leak fixes 2022-02-08 20:33:16 -05:00
Dave Horton
cfe9397502 lint 2022-02-03 07:36:01 -05:00
Dave Horton
dda3335060 update deps, add helmet middleware 2022-02-03 07:31:30 -05:00
Dave Horton
2329f0cda0 child tasks must remove reference to parent on kill or else entangled parent-child tasks will not be gc'ed 2022-02-01 11:00:12 -05:00
Dave Horton
36683dc151 bugfix: include custom jambonz headers on rest outdial 2022-01-28 13:36:06 -05:00
Dave Horton
ce738a7852 0.7.2 version 2022-01-28 09:16:05 -05:00
Dave Horton
77a696a0dc update to latest synthAudio with minor fixes 2022-01-27 13:52:35 -05:00
Dave Horton
62ff44540d more changes for wellsaid 2022-01-27 10:55:32 -05:00
Dave Horton
e5821cddf8 further fix for wellsaid tts 2022-01-27 10:46:16 -05:00
Dave Horton
25567a7842 add support for retrieving wellsaid speech credential 2022-01-27 10:34:30 -05:00
Dave Horton
40bd3c9c88 update to realtimedb-helpers with support for wellsaid tts 2022-01-27 10:13:18 -05:00
Dave Horton
27d6d32359 bugfix: rtpengine needs to transcode when different codecs are used on A and B legs 2022-01-26 07:37:09 -05:00
Dave Horton
142f5d409f use smpp service name when running in kubernetes 2022-01-25 13:29:16 -05:00
Dave Horton
da4a7184a4 update to realtimedb-helpers with engine caching fix for tts 2022-01-22 15:35:01 -05:00
Dave Horton
2c72bf50cd sync package-lock.json 2022-01-21 22:04:07 -05:00
Dave Horton
b27f349fc6 linting 2022-01-21 10:15:33 -05:00
Dave Horton
138aa5836a lock version 2022-01-21 10:13:42 -05:00
Dave Horton
e1a023c21e bugfix: aws property is engine not platform 2022-01-21 09:57:58 -05:00
Dave Horton
8acb4d1a24 #58 - add support for platform (standard, or neural) when using aws tts 2022-01-19 19:46:24 -05:00
Dave Horton
26d4bfb63b Cognigy: settings tweaks 2022-01-18 19:49:46 -05:00
Dave Horton
45dcab8517 fix linting error 2022-01-17 20:37:32 -05:00
Dave Horton
27e3cba00b fix vulnerabilities 2022-01-17 18:41:12 -05:00
Dave Horton
097f36cb00 bugix: re-invites after releasing media fail 2022-01-17 13:11:19 -05:00
Dave Horton
752eed428f cognigy: when use azuyre tts, request detailed output format 2022-01-14 08:48:55 -05:00
Dave Horton
afb874aabc minor logging change 2022-01-14 07:56:11 -05:00
Dave Horton
59227febf9 K8s (#57)
* JAMBONES_NETWORK_CIDR not needed for K8S

* fix bug setting fsUUID in K8S scenario

* bugfix: dial music was not stopped when a dial verb times out (#56)
2022-01-09 14:57:46 -05:00
Dave Horton
8593f12b51 add custom headers to outdial, save unique uuid for running FS to redis 2022-01-08 11:50:18 -05:00
Dave Horton
3bf1984854 K8s changes (#55)
* K8S: dont send OPTIONS pings

* fix missing ref

* k8s pre-stop hook added

* k8s pre-stop hook changes

* chmod +x utility

* more k8s pre-stop changes

* pre stop

* fix healthcheck

* k8s pre-stop working

* add readiness probe

* fix bug in pre-stop

* logging

* revamp k8s pre-stop a bit

* initial support for cognigy bot

* more cognigy changes

* switch to use transcribe for cognigy

* #54 include callInfo in dialogflow event payload
2022-01-06 12:41:14 -05:00
Dave Horton
0e45e9b27c add target.overrideTo to specs 2021-12-22 08:32:56 -05:00
Dave Horton
b0a8a6828d bugfix: use of tag resulted in redis insert failures 2021-12-21 20:42:53 -05:00
Dave Horton
27d4ad5674 bump version 2021-12-21 09:39:44 -05:00
Dave Horton
d38e77c06c bugfix: support looking up application by regex in addition to exact phone number match 2021-12-20 15:37:21 -05:00
Dave Horton
c9e2a162c2 lookupAppByPhoneNumber: pass voip_carrier_sid if available 2021-12-20 10:04:54 -05:00
Dave Horton
2b9cb5105f clean up handlers 2021-12-15 19:33:31 -05:00
Dave Horton
afbbed3f5c default options ping interval to 30s, with env override if desired 2021-12-14 12:28:02 -05:00
Dave Horton
f642967f02 add SIGTERM handler 2021-12-13 18:08:53 -05:00
Dave Horton
fbe2aa2c06 add SIGUSR2 handler to remove fs from redis set 2021-12-13 17:59:23 -05:00
Dave Horton
5321b5c651 minor change to dial _releaseMedia 2021-12-13 13:22:09 -05:00
Dave Horton
83c114803f minor logging 2021-12-13 11:48:43 -05:00
Dave Horton
0663174f46 additional logging 2021-12-12 09:30:36 -05:00
Dave Horton
3d4359fbe4 fix bug from prev checkin, destroy does not return a promise 2021-12-09 11:24:52 -05:00
Dave Horton
10382573fa clean up some retainers 2021-12-09 10:44:50 -05:00
Dave Horton
c190279927 bugfix: enqueue task was only invoking waitUrl a single time 2021-12-06 21:18:51 -05:00
Dave Horton
114f65b36a add env LEGACY_CRYPTO 2021-11-29 09:03:43 -05:00
Dave Horton
3e49616191 Feature/specify trunk on dial (#47)
* #25: allow application to specify a specific SIP trunk on dial verb

* more fixes
2021-11-28 11:10:53 -05:00
Dave Horton
1e93973419 Feature/azure recognition (#46)
* add support for microsoft speech recognition

* update to drachtio-fsmrf that support microsoft stt

* gather and transcribe now support microsoft
2021-11-26 16:40:25 -06:00
Dave Horton
fe1778e9ae Feature/sip refer (#44)
* changes to support sip REFER

* implement actionhook

* changes from testing

* minor logging
2021-11-20 11:39:10 -05:00
Dave Horton
af15449451 fix tests 2021-11-19 14:17:10 -05:00
Dave Horton
12c34de15c changes for azure tts 2021-11-19 18:28:42 +00:00
Dave Horton
7c77bedd15 linting 2021-11-19 10:25:11 -05:00
Dave Horton
0c5150cb30 add support for recording conference to a file 2021-11-19 10:07:43 -05:00
Dave Horton
2262973f43 bugfix #41: error was thrown about missing speech creds when speech was not enabled 2021-11-16 19:42:16 -05:00
Dave Horton
db78ffffed dial: make sure to clear max call timer when dial ends 2021-11-15 12:00:48 -05:00
Dave Horton
2930cd6aaf Dockerfile 2021-11-04 12:57:56 -04:00
Dave Horton
2a013377cc update to aes-256-cbc algorithm for encryption 2021-11-03 16:17:20 -04:00
Dave Horton
dcf27ba5d3 trim sensitive info from logs 2021-11-03 14:37:57 -04:00
Dave Horton
f11feb7975 version bump 2021-11-03 13:49:35 -04:00
Dave Horton
19dda9398d bump version 2021-10-21 13:08:45 -04:00
Dave Horton
81edf1a6d6 bump version 2021-10-21 13:00:29 -04:00
Dave Horton
72345f83c1 Feature/minimal media anchoring (#36)
* initial WIP to remove freeswitch from media path when not recording or transcribing dial calls

* implement release-media and anchor-media operations

* mute/unmute now handled by rtpengine

* Dial: dtmf detection now based on SIP INFO events from sbcs and rtpengine

* add reason to gather action, bugfixes for transcribe and say
2021-10-21 11:59:45 -04:00
Dave Horton
bedf25c6a2 update to latest realtimedb 2021-10-02 14:19:05 -04:00
Dave Horton
a9e789f466 add support for autoscaling SBC SIP servers; bugfix: synthAudio calls must past stats obj 2021-10-02 12:40:56 -04:00
Dave Horton
a779ead79f minor fix for gather 2021-09-29 18:15:52 -04:00
Dave Horton
a3d3878218 bugfix: cs not passed to kill() 2021-09-28 09:58:59 -04:00
Dave Horton
4bc3e03605 bugfix: 302 response in rest outdial caused restart 2021-09-27 10:39:17 -04:00
Dave Horton
62106a751f fix bug in createCall 2021-09-27 08:41:45 -04:00
Dave Horton
4c61ae5fbd add support for conference members joining in a muted state 2021-09-25 13:50:16 -04:00
Dave Horton
708c13d5f6 add support for muting/unmuting non moderators in a conference 2021-09-25 12:31:20 -04:00
Dave Horton
7cf342eeb8 add support for overrideTo and 302 redirect on rest outdial 2021-09-24 09:58:39 -04:00
Dave Horton
aebcf2b006 say now supports loop="forever" 2021-09-24 07:01:26 -04:00
Dave Horton
f0bd681ccc implement actionHook for message verb 2021-09-22 13:28:56 -04:00
Dave Horton
ac263de729 fix error responses for sms 2021-09-22 10:54:36 -04:00
Dave Horton
862405c232 LCC: add conference hold and unhold actions 2021-09-22 07:39:44 -04:00
Dave Horton
3cd4c399d4 LCC: add support for conf_hold_status to hold/unhold a participant in a conference 2021-09-20 15:50:00 -04:00
Dave Horton
0d6cb8a2b3 bugfix: establish conference start time for parties that have been waiting 2021-09-16 13:08:15 -04:00
Dave Horton
05c5319cbc minor rasa fix 2021-09-07 13:43:40 -04:00
Dave Horton
d15fdcf663 rasa: add support for eventhook which provides user and bot messages in realtime and supports redirecting to a new app 2021-09-07 13:43:40 -04:00
Dave Horton
19f3cbaa43 initial support for Rasa 2021-09-07 13:43:40 -04:00
Dave Horton
ac8827c885 dialogflow: support for regional endpoints 2021-09-07 13:43:40 -04:00
Dave Horton
d1d082ceaf fix vulnerability 2021-08-30 17:12:44 -04:00
Dave Horton
28415dc750 bugfixes for queue events 2021-08-30 17:12:00 -04:00
Dave Horton
3d0c7fea52 add support for bidirectional audio when using listen verb 2021-08-26 15:19:05 -04:00
Dave Horton
3fed15b3b9 further fixes for customerData 2021-08-11 11:01:11 -04:00
Dave Horton
7c629e6faf bugfix: customerData in webhooks was being snake-cased 2021-08-11 10:47:10 -04:00
Dave Horton
649b3d5715 race condition: dial call killed just as called party picks up 2021-08-10 11:01:10 -04:00
Dave Horton
48fbbd48ad add try-catch block 2021-08-09 16:20:58 -04:00
Dave Horton
dacd3691ed bugfix: enqueue queue_result = bridged if queued call was answered 2021-08-04 08:53:37 -04:00
Dave Horton
df8dac367c bugfix: if waitUrl of enqueue task includes leave but caller is dequeued before leave is reached, ignore leave 2021-08-03 16:46:02 -04:00
Dave Horton
1a2aaf9845 Feature/queue webhooks (#34)
* initial changes for queue webhooks

* send queue leave webhook when dequeued

* 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

* remove leftover merge brackets
2021-07-31 13:32:40 -04:00
46 changed files with 3988 additions and 1376 deletions

View File

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

51
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: Docker
on:
push:
# Publish `main` as Docker `latest` image.
branches:
- main
# Publish `v1.2.3` tags as releases.
tags:
- v*
env:
IMAGE_NAME: feature-server
jobs:
push:
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v2
- name: Build image
run: docker build . --file Dockerfile --tag $IMAGE_NAME
- name: Log into registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Push image
run: |
IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME
# Change all uppercase to lowercase
IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
# Strip git ref prefix from version
VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
# Strip "v" prefix from tag name
[[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')
# Use Docker `latest` tag convention
[ "$VERSION" == "main" ] && VERSION=latest
echo IMAGE_ID=$IMAGE_ID
echo VERSION=$VERSION
docker tag $IMAGE_NAME $IMAGE_ID:$VERSION
docker push $IMAGE_ID:$VERSION

View File

@@ -1,16 +1,10 @@
FROM node:alpine as builder
RUN apk update && apk add --no-cache python make g++
FROM node:17.4-slim
WORKDIR /opt/app/
COPY package.json ./
RUN npm install
RUN npm prune
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
CMD [ "npm", "start" ]
CMD [ "npm", "start" ]

23
app.js
View File

@@ -7,7 +7,7 @@ assert.ok(process.env.DRACHTIO_PORT || process.env.DRACHTIO_HOST, 'missing DRACH
assert.ok(process.env.DRACHTIO_SECRET, 'missing DRACHTIO_SECRET env var');
assert.ok(process.env.JAMBONES_FREESWITCH, 'missing JAMBONES_FREESWITCH env var');
assert.ok(process.env.JAMBONES_REDIS_HOST, 'missing JAMBONES_REDIS_HOST env var');
assert.ok(process.env.JAMBONES_NETWORK_CIDR, 'missing JAMBONES_SUBNET env var');
assert.ok(process.env.JAMBONES_NETWORK_CIDR || process.env.K8S, 'missing JAMBONES_SUBNET env var');
const Srf = require('drachtio-srf');
const srf = new Srf();
@@ -17,7 +17,7 @@ const opts = {
level: process.env.JAMBONES_LOGLEVEL || 'info'
};
const logger = require('pino')(opts);
const {LifeCycleEvents} = require('./lib/utils/constants');
const {LifeCycleEvents, FS_UUID_SET_NAME} = require('./lib/utils/constants');
const installSrfLocals = require('./lib/utils/install-srf-locals');
installSrfLocals(srf, logger);
@@ -31,6 +31,7 @@ const {
// HTTP
const express = require('express');
const helmet = require('helmet');
const app = express();
Object.assign(app.locals, {
logger,
@@ -73,6 +74,8 @@ srf.invite((req, res) => {
});
// HTTP
app.use(helmet());
app.use(helmet.hidePoweredBy());
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use('/', httpRoutes);
@@ -92,6 +95,10 @@ sessionTracker.on('idle', () => {
}
});
const getCount = () => sessionTracker.count;
const healthCheck = require('@jambonz/http-health-check');
healthCheck({app, logger, path: '/', fn: getCount});
setInterval(() => {
srf.locals.stats.gauge('fs.sip.calls.count', sessionTracker.count);
}, 5000);
@@ -105,4 +112,16 @@ const disconnect = () => {
});
};
process.on('SIGUSR2', handle);
process.on('SIGTERM', handle);
function handle(signal) {
const {removeFromSet} = srf.locals.dbHelpers;
const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-fs`;
logger.info(`got signal ${signal}, removing ${srf.locals.localSipAddress} from set ${setName}`);
removeFromSet(setName, srf.locals.localSipAddress);
removeFromSet(FS_UUID_SET_NAME, srf.locals.fsUUID);
srf.locals.disabled = true;
}
module.exports = {srf, logger, disconnect};

29
bin/k8s-pre-stop-hook.js Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env node
const bent = require('bent');
const getJSON = bent('json');
const PORT = process.env.HTTP_PORT || 3000;
const sleep = (ms) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
(async function() {
try {
do {
const obj = await getJSON(`http://127.0.0.1:${PORT}/`);
const {calls} = obj;
if (calls === 0) {
console.log('no calls on the system, we can exit');
process.exit(0);
}
else {
console.log(`waiting for ${calls} to exit..`);
}
await sleep(10000);
} while (1);
} catch (err) {
console.error(err, 'Error querying health endpoint');
process.exit(-1);
}
})();

View File

@@ -35,6 +35,8 @@ router.post('/', async(req, res) => {
opts.headers = {
...opts.headers,
'X-Jambonz-Routing': target.type,
'X-Jambonz-FS-UUID': srf.locals.fsUUID,
'X-Call-Sid': callSid,
'X-Account-Sid': req.body.account_sid
};
@@ -57,6 +59,11 @@ router.post('/', async(req, res) => {
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;
@@ -105,13 +112,17 @@ 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');
return;
}
inviteReq.srf = srf;
/* ok our outbound INVITE is in flight */
const tasks = [restDial];

View File

@@ -9,8 +9,4 @@ 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));
module.exports = api;

View File

@@ -1,16 +1,23 @@
const express = require('express');
const api = require('./api');
const routes = express.Router();
const sessionTracker = require('../session/session-tracker');
const readiness = (req, res) => {
const logger = req.app.locals.logger;
const {count} = sessionTracker;
const {srf} = require('../..');
const {getFreeswitch} = srf.locals;
if (getFreeswitch()) {
return res.status(200).json({calls: count});
}
logger.info('responding to /health check with failure as freeswitch is not up');
res.sendStatus(480);
};
routes.use('/v1', api);
// health checks
routes.get('/', (req, res) => {
res.sendStatus(200);
});
routes.get('/health', (req, res) => {
res.sendStatus(200);
});
// health check
routes.get('/health', readiness);
module.exports = routes;

View File

@@ -8,7 +8,13 @@ const normalizeJambones = require('./utils/normalize-jambones');
const dbUtils = require('./utils/db-utils');
module.exports = function(srf, logger) {
const {lookupAppByPhoneNumber, lookupAppBySid, lookupAppByRealm, lookupAppByTeamsTenant} = srf.locals.dbHelpers;
const {
lookupAppByPhoneNumber,
lookupAppByRegex,
lookupAppBySid,
lookupAppByRealm,
lookupAppByTeamsTenant
} = srf.locals.dbHelpers;
const {lookupAccountDetails} = dbUtils(logger, srf);
function initLocals(req, res, next) {
const callSid = req.has('X-Retain-Call-Sid') ? req.get('X-Retain-Call-Sid') : uuidv4();
@@ -45,7 +51,7 @@ module.exports = function(srf, logger) {
// TODO: alert
return res.send(503, {headers: {'X-Reason': 'Account exists but is inactive'}});
}
logger.debug({accountInfo: req.locals.accountInfo}, `retrieved account info for ${account_sid}`);
logger.debug({accountInfo: req.locals?.accountInfo?.account}, `retrieved account info for ${account_sid}`);
next();
} catch (err) {
logger.info({err}, `Error retrieving account details for account ${account_sid}`);
@@ -112,7 +118,15 @@ module.exports = function(srf, logger) {
logger.error(err, `Error retrieving transferred call app for ${arr[1]}`);
}
}
else app = await lookupAppByPhoneNumber(req.locals.calledNumber);
else {
const voip_carrier_sid = req.get('X-Voip-Carrier-Sid');
app = await lookupAppByPhoneNumber(req.locals.calledNumber, voip_carrier_sid);
if (!app) {
/* lookup by call_routes.regex */
app = await lookupAppByRegex(req.locals.calledNumber, account_sid);
}
}
}
if (!app || !app.call_hook || !app.call_hook.url) {
@@ -137,7 +151,9 @@ module.exports = function(srf, logger) {
const obj = Object.assign({}, app);
delete obj.requestor;
delete obj.notifier;
logger.info({app: obj}, `retrieved application for incoming call to ${req.locals.calledNumber}`);
// eslint-disable-next-line no-unused-vars
const {call_hook, call_status_hook, ...appInfo} = obj; // mask sensitive data like user/pass on webhook
logger.info({app: appInfo}, `retrieved application for incoming call to ${req.locals.calledNumber}`);
req.locals.callInfo = new CallInfo({req, app, direction: CallDirection.Inbound});
next();
} catch (err) {
@@ -167,7 +183,7 @@ module.exports = function(srf, logger) {
if (0 === app.tasks.length) throw new Error('no application provided');
next();
} catch (err) {
logger.info(`Error retrieving or parsing application: ${err.message}`);
logger.info({err}, `Error retrieving or parsing application: ${err.message}`);
res.send(480, {headers: {'X-Reason': err.message}});
}
}

View File

@@ -8,13 +8,14 @@ const CallSession = require('./call-session');
*/
class AdultingCallSession extends CallSession {
constructor({logger, application, singleDialer, tasks, callInfo}) {
constructor({logger, application, singleDialer, tasks, callInfo, accountInfo}) {
super({
logger,
application,
srf: singleDialer.dlg.srf,
tasks,
callInfo
callInfo,
accountInfo
});
this.sd = singleDialer;

View File

@@ -1,7 +1,6 @@
const {CallDirection, CallStatus} = require('../utils/constants');
const parseUri = require('drachtio-srf').parseUri;
const { v4: uuidv4 } = require('uuid');
/**
* @classdesc Represents the common information for all calls
* that is provided in call status webhooks
@@ -9,6 +8,7 @@ const { v4: uuidv4 } = require('uuid');
class CallInfo {
constructor(opts) {
let from ;
let srf;
this.direction = opts.direction;
if (opts.req) {
const u = opts.req.getParsedHeader('from');
@@ -19,6 +19,7 @@ class CallInfo {
if (this.direction === CallDirection.Inbound) {
// inbound call
const {app, req} = opts;
srf = req.srf;
this.callSid = req.locals.callSid,
this.accountSid = app.account_sid,
this.applicationSid = app.application_sid;
@@ -33,6 +34,7 @@ class CallInfo {
else if (opts.parentCallInfo) {
// outbound call that is a child of an existing call
const {req, parentCallInfo, to, callSid} = opts;
srf = req.srf;
this.callSid = callSid || uuidv4();
this.parentCallSid = parentCallInfo.callSid;
this.accountSid = parentCallInfo.accountSid;
@@ -47,6 +49,7 @@ class CallInfo {
else if (this.direction === CallDirection.None) {
// outbound SMS
const {messageSid, accountSid, applicationSid, res} = opts;
srf = res.srf;
this.messageSid = messageSid;
this.accountSid = accountSid;
this.applicationSid = applicationSid;
@@ -55,6 +58,7 @@ class CallInfo {
else {
// outbound call triggered by REST
const {req, callSid, accountSid, applicationSid, to, tag} = opts;
srf = req.srf;
this.callSid = callSid;
this.accountSid = accountSid;
this.applicationSid = applicationSid;
@@ -65,6 +69,11 @@ class CallInfo {
this.to = to;
if (tag) this._customerData = tag;
}
this.localSipAddress = srf.locals.localSipAddress;
if (srf.locals.publicIp) {
this.publicIp = srf.locals.publicIp;
}
}
/**
@@ -100,7 +109,8 @@ class CallInfo {
callStatus: this.callStatus,
callerId: this.callerId,
accountSid: this.accountSid,
applicationSid: this.applicationSid
applicationSid: this.applicationSid,
fsSipAddress: this.localSipAddress
};
['parentCallSid', 'originatingSipIp', 'originatingSipTrunkName'].forEach((prop) => {
if (this[prop]) obj[prop] = this[prop];
@@ -110,6 +120,13 @@ class CallInfo {
if (this._customerData) {
Object.assign(obj, {customerData: this._customerData});
}
if (process.env.JAMBONES_API_BASE_URL) {
Object.assign(obj, {apiBaseUrl: process.env.JAMBONES_API_BASE_URL});
}
if (this.publicIp) {
Object.assign(obj, {fsPublicIp: this.publicIp});
}
return obj;
}

View File

@@ -34,7 +34,7 @@ class CallSession extends Emitter {
* @param {array} opts.tasks - tasks we are to execute
* @param {callInfo} opts.callInfo - information about the call
*/
constructor({logger, application, srf, tasks, callInfo, accountInfo}) {
constructor({logger, application, srf, tasks, callInfo, accountInfo, memberId, confName, confUuid}) {
super();
this.logger = logger;
this.application = application;
@@ -42,9 +42,13 @@ class CallSession extends Emitter {
this.callInfo = callInfo;
this.accountInfo = accountInfo;
this.tasks = tasks;
this.memberId = memberId;
this.confName = confName;
this.confUuid = confUuid;
this.taskIdx = 0;
this.stackIdx = 0;
this.callGone = false;
this.notifiedComplete = false;
this.tmpFiles = new Set();
@@ -192,13 +196,37 @@ class CallSession extends Emitter {
return this.constructor.name === 'SmsCallSession';
}
get webhook_secret() {
return this.accountInfo?.account?.webhook_secret;
}
get isInConference() {
return this.memberId && this.confName && this.confUuid;
}
setConferenceDetails(memberId, confName, confUuid) {
assert(!this.memberId && !this.confName && !this.confUuid);
assert (memberId && confName && confUuid);
this.logger.debug(`session is now in conference ${confName}:${memberId} - uuid ${confUuid}`);
this.memberId = memberId;
this.confName = confName;
this.confUuid = confUuid;
}
clearConferenceDetails() {
this.logger.debug(`session has now left conference ${this.confName}:${this.memberId}`);
this.memberId = null;
this.confName = null;
this.confUuid = null;
}
/**
* Check for speech credentials for the specified vendor
* @param {*} vendor - google or aws
*/
getSpeechCredentials(vendor, type) {
const {writeAlerts, AlertType} = this.srf.locals;
this.logger.debug({vendor, type, speech: this.accountInfo.speech}, `searching for speech for vendor ${vendor}`);
if (this.accountInfo.speech && this.accountInfo.speech.length > 0) {
const credential = this.accountInfo.speech.find((s) => s.vendor === vendor);
if (credential && (
@@ -230,6 +258,19 @@ class CallSession extends Emitter {
region: process.env.AWS_REGION || credential.aws_region
};
}
else if ('microsoft' === vendor) {
return {
speech_credential_sid: credential.speech_credential_sid,
api_key: credential.api_key,
region: credential.region
};
}
else if ('wellsaid' === vendor) {
return {
speech_credential_sid: credential.speech_credential_sid,
api_key: credential.api_key
};
}
}
else {
writeAlerts({
@@ -336,7 +377,7 @@ class CallSession extends Emitter {
*/
_lccCallStatus(opts) {
if (opts.call_status === CallStatus.Completed && this.dlg) {
this.logger.info('CallSession:updateCall hanging up call due to request from api');
this.logger.info('CallSession:_lccCallStatus hanging up call due to request from api');
this._callerHungup();
}
else if (opts.call_status === CallStatus.NoAnswer) {
@@ -364,13 +405,13 @@ class CallSession extends Emitter {
async _lccCallHook(opts) {
const webhooks = [];
let sd;
if (opts.call_hook) webhooks.push(this.requestor.request(opts.call_hook, this.callInfo));
if (opts.call_hook) webhooks.push(this.requestor.request(opts.call_hook, this.callInfo.toJSON()));
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));
webhooks.push(this.requestor.request(opts.child_call_hook, sd.callInfo.toJSON()));
}
}
const [tasks1, tasks2] = await Promise.all(webhooks);
@@ -385,7 +426,7 @@ class CallSession extends Emitter {
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');
childLogger.info({tasks: listTaskNames(t)}, 'CallSession:_lccCallHook new task list for child call');
const cs = await sd.doAdulting({
logger: childLogger,
application: this.application,
@@ -397,7 +438,7 @@ class CallSession extends Emitter {
}
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.logger.info({tasks: listTaskNames(t)}, 'CallSession:_lccCallHook new task list');
this.replaceApplication(t);
}
else {
@@ -414,23 +455,39 @@ class CallSession extends Emitter {
async _lccListenStatus(opts) {
const task = this.currentTask;
if (!task || ![TaskName.Dial, TaskName.Listen].includes(task.name)) {
return this.logger.info(`CallSession:updateCall - invalid listen_status in task ${task.name}`);
return this.logger.info(`CallSession:_lccListenStatus - invalid listen_status in task ${task.name}`);
}
const listenTask = task.name === TaskName.Listen ? task : task.listenTask;
if (!listenTask) {
return this.logger.info('CallSession:updateCall - invalid listen_status: Dial does not have a listen');
return this.logger.info('CallSession:_lccListenStatus - invalid listen_status: Dial does not have a listen');
}
listenTask.updateListen(opts.listen_status);
}
async _lccMuteStatus(callSid, mute) {
// this whole thing requires us to be in a Dial verb
// this whole thing requires us to be in a Dial or Conference verb
const task = this.currentTask;
if (!task || TaskName.Dial !== task.name) {
return this.logger.info('CallSession:_lccMute - invalid command as dial is not active');
if (!task || ![TaskName.Dial, TaskName.Conference].includes(task.name)) {
return this.logger.info('CallSession:_lccMuteStatus - invalid: neither dial nor conference are not active');
}
// now do the whisper
task.mute(callSid, mute).catch((err) => this.logger.error(err, 'CallSession:_lccMute'));
// now do the mute/unmute
task.mute(callSid, mute).catch((err) => this.logger.error(err, 'CallSession:_lccMuteStatus'));
}
async _lccConfHoldStatus(callSid, opts) {
const task = this.currentTask;
if (!task || TaskName.Conference !== task.name || !this.isInConference) {
return this.logger.info('CallSession:_lccConfHoldStatus - invalid command as call is not in conference');
}
task.doConferenceHold(this, opts);
}
async _lccConfMuteStatus(callSid, opts) {
const task = this.currentTask;
if (!task || TaskName.Conference !== task.name || !this.isInConference) {
return this.logger.info('CallSession:_lccConfHoldStatus - invalid command as call is not in conference');
}
task.doConferenceMuteNonModerators(this, opts);
}
/**
@@ -450,7 +507,7 @@ class CallSession extends Emitter {
// allow user to provide a url object, a url string, an array of tasks, or a single task
if (typeof whisper === 'string' || (typeof whisper === 'object' && whisper.url)) {
// retrieve a url
const json = await this.requestor(opts.call_hook, this.callInfo);
const json = await this.requestor(opts.call_hook, this.callInfo.toJSON());
tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
}
else if (Array.isArray(whisper)) {
@@ -481,20 +538,6 @@ class CallSession extends Emitter {
task.whisper(tasks, callSid).catch((err) => this.logger.error(err, 'CallSession:_lccWhisper'));
}
/**
* perform live call control -- mute or unmute an endpoint
* @param {array} opts - array of play or say tasks
*/
async _lccMute(callSid, mute) {
// 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:_lccMute - not possible since we are not in a dial');
}
task.mute(callSid, mute).catch((err) => this.logger.error(err, 'CallSession:_lccMute'));
}
/**
* perform live call control
@@ -516,6 +559,12 @@ class CallSession extends Emitter {
else if (opts.mute_status) {
await this._lccMuteStatus(callSid, opts.mute_status === 'mute');
}
else if (opts.conf_hold_status) {
await this._lccConfHoldStatus(callSid, opts);
}
else if (opts.conf_mute_status) {
await this._lccConfMuteStatus(callSid, opts);
}
// whisper may be the only thing we are asked to do, or it may that
// we are doing a whisper after having muted, paused reccording etc..
@@ -590,7 +639,7 @@ class CallSession extends Emitter {
try {
if (!this.ms) this.ms = this.getMS();
const ep = await this.ms.createEndpoint({remoteSdp: this.req.body});
ep.cs = this;
//ep.cs = this;
this.ep = ep;
ep.set({
hangup_after_bridge: false,
@@ -674,14 +723,12 @@ class CallSession extends Emitter {
/**
* Hang up the call and free the media endpoint
*/
async _clearResources() {
_clearResources() {
for (const resource of [this.dlg, this.ep]) {
try {
if (resource && resource.connected) await resource.destroy();
} catch (err) {
this.logger.error(err, 'CallSession:_clearResources error');
}
if (resource && resource.connected) resource.destroy();
}
this.dlg = null;
this.ep = null;
// remove any temporary tts files that were created (audio is still cached in redis)
for (const path of this.tmpFiles) {
@@ -740,9 +787,19 @@ class CallSession extends Emitter {
async _onReinvite(req, res) {
try {
const newSdp = await this.ep.modify(req.body);
res.send(200, {body: newSdp});
this.logger.info({offer: req.body, answer: newSdp}, 'handling reINVITE');
if (this.ep) {
const newSdp = await this.ep.modify(req.body);
res.send(200, {body: newSdp});
this.logger.info({offer: req.body, answer: newSdp}, 'handling reINVITE');
}
else if (this.currentTask && this.currentTask.name === TaskName.Dial) {
this.logger.info('handling reINVITE after media has been released');
await this.currentTask.handleReinviteAfterMediaReleased(req, res);
}
else {
this.logger.info('got reINVITE but no endpoint and media has not been released');
res.send(488);
}
} catch (err) {
this.logger.error(err, 'Error handling reinvite');
}
@@ -784,7 +841,8 @@ class CallSession extends Emitter {
}
else {
this.logger.info({accountSid: this.accountSid, webhook: r[0]}, 'performQueueWebhook: webhook found');
this.queueEventHookRequestor = new Requestor(this.logger, r[0]);
this.queueEventHookRequestor = new Requestor(this.logger, this.accountSid,
r[0], this.webhook_secret);
this.queueEventHook = r[0];
}
} catch (err) {
@@ -890,6 +948,35 @@ class CallSession extends Emitter {
};
}
async releaseMediaToSBC(remoteSdp) {
assert(this.dlg && this.dlg.connected && this.ep && typeof remoteSdp === 'string');
await this.dlg.modify(remoteSdp, {
headers: {
'X-Reason': 'release-media'
}
});
this.ep.destroy()
.then(() => this.ep = null)
.catch((err) => this.logger.error({err}, 'CallSession:releaseMediaToSBC: Error destroying endpoint'));
}
async reAnchorMedia() {
assert(this.dlg && this.dlg.connected && !this.ep);
this.ep = await this.ms.createEndpoint({remoteSdp: this.dlg.remote.sdp});
await this.dlg.modify(this.ep.local.sdp, {
headers: {
'X-Reason': 'anchor-media'
}
});
}
async handleReinviteAfterMediaReleased(req, res) {
assert(this.dlg && this.dlg.connected && !this.ep);
const sdp = await this.dlg.modify(req.body);
this.logger.info({sdp}, 'CallSession:handleReinviteAfterMediaReleased - reinvite to A leg returned sdp');
res.send(200, {body: sdp});
}
/**
* Called any time call status changes. This method both invokes the
* call_status_hook callback as well as updates the realtime database
@@ -902,6 +989,12 @@ class CallSession extends Emitter {
_notifyCallStatusChange({callStatus, sipStatus, duration}) {
if (this.callMoved) return;
/* race condition: we hang up at the same time as the caller */
if (callStatus === CallStatus.Completed) {
if (this.notifiedComplete) return;
this.notifiedComplete = true;
}
assert((typeof duration === 'number' && callStatus === CallStatus.Completed) ||
(!duration && callStatus !== CallStatus.Completed),
'duration MUST be supplied when call completed AND ONLY when call completed');
@@ -909,14 +1002,14 @@ class CallSession extends Emitter {
this.callInfo.updateCallStatus(callStatus, sipStatus);
if (typeof duration === 'number') this.callInfo.duration = duration;
try {
this.notifier.request(this.call_status_hook, this.callInfo);
this.notifier.request(this.call_status_hook, this.callInfo.toJSON());
} catch (err) {
this.logger.info(err, `CallSession:_notifyCallStatusChange error sending ${callStatus} ${sipStatus}`);
}
// update calls db
//this.logger.debug(`updating redis with ${JSON.stringify(this.callInfo)}`);
this.updateCallStatus(Object.assign({}, this.callInfo), this.serviceUrl)
this.updateCallStatus(Object.assign({}, this.callInfo.toJSON()), this.serviceUrl)
.catch((err) => this.logger.error(err, 'redis error'));
}
}

View File

@@ -8,14 +8,17 @@ const CallSession = require('./call-session');
*/
class ConfirmCallSession extends CallSession {
constructor({logger, application, dlg, ep, tasks, callInfo}) {
constructor({logger, application, dlg, ep, tasks, callInfo, accountInfo, memberId, confName}) {
super({
logger,
application,
srf: dlg.srf,
callSid: dlg.callSid,
tasks,
callInfo
callInfo,
accountInfo,
memberId,
confName
});
this.dlg = dlg;
this.ep = ep;

View File

@@ -21,15 +21,17 @@ class InboundCallSession extends CallSession {
this.req = req;
this.res = res;
req.on('cancel', () => {
this._notifyCallStatusChange({callStatus: CallStatus.NoAnswer, sipStatus: 487});
this._callReleased();
});
req.once('cancel', this._onCancel.bind(this));
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
this._notifyCallStatusChange({callStatus: CallStatus.Trying, sipStatus: 100});
}
_onCancel() {
this._notifyCallStatusChange({callStatus: CallStatus.NoAnswer, sipStatus: 487});
this._callReleased();
}
_onTasksDone() {
if (!this.res.finalResponseSent) {
if (this._mediaServerFailure) {
@@ -45,6 +47,7 @@ class InboundCallSession extends CallSession {
this.res.send(603);
}
}
this.req.removeAllListeners('cancel');
}
/**
@@ -56,6 +59,7 @@ class InboundCallSession extends CallSession {
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
this.logger.debug('InboundCallSession: caller hung up');
this._callReleased();
this.req.removeAllListeners('cancel');
}
}

248
lib/tasks/cognigy.js Normal file
View File

@@ -0,0 +1,248 @@
const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants');
const makeTask = require('./make_task');
const { SocketClient } = require('@cognigy/socket-client');
const parseGallery = (obj = {}) => {
const {_default} = obj;
if (_default) {
const {_gallery} = _default;
if (_gallery) return _gallery.fallbackText;
}
};
const parseQuickReplies = (obj) => {
const {_default} = obj;
if (_default) {
const {_quickReplies} = _default;
if (_quickReplies) return _quickReplies.text || _quickReplies.fallbackText;
}
};
const parseBotText = (evt) => {
const {text, data} = evt;
if (text) return text;
switch (data?.type) {
case 'quickReplies':
return parseQuickReplies(data?._cognigy);
case 'gallery':
return parseGallery(data?._cognigy);
default:
break;
}
};
class Cognigy extends Task {
constructor(logger, opts) {
super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint;
this.url = this.data.url;
this.token = this.data.token;
this.prompt = this.data.prompt;
this.eventHook = this.data?.eventHook;
this.actionHook = this.data?.actionHook;
this.data = this.data.data || {};
this.prompts = [];
}
get name() { return TaskName.Cognigy; }
get hasReportedFinalAction() {
return this.reportedFinalAction || this.isReplacingApplication;
}
async exec(cs, ep) {
await super.exec(cs);
this.ep = ep;
try {
/* set event handlers and start transcribing */
this.on('transcription', this._onTranscription.bind(this, cs, ep));
this.on('error', this._onError.bind(this, cs, ep));
this.transcribeTask = this._makeTranscribeTask();
this.transcribeTask.exec(cs, ep, this)
.catch((err) => {
this.logger.info({err}, 'Cognigy transcribe task returned error');
this.notifyTaskDone();
});
if (this.prompt) {
this.sayTask = this._makeSayTask(this.prompt);
this.sayTask.exec(cs, ep, this)
.catch((err) => {
this.logger.info({err}, 'Cognigy say task returned error');
this.notifyTaskDone();
});
}
/* connect to the bot and send initial data */
this.client = new SocketClient(
this.url,
this.token,
{
sessionId: cs.callSid,
channel: 'jambonz',
forceWebsockets: true,
reconnection: true,
settings: {
enableTypingIndicator: false
}
}
);
this.client.on('output', this._onBotUtterance.bind(this, cs, ep));
this.client.on('typingStatus', this._onBotTypingStatus.bind(this, cs, ep));
this.client.on('error', this._onBotError.bind(this, cs, ep));
this.client.on('finalPing', this._onBotFinalPing.bind(this, cs, ep));
await this.client.connect();
this.client.sendMessage('', {...this.data, ...cs.callInfo});
await this.awaitTaskDone();
} catch (err) {
this.logger.error({err}, 'Cognigy error');
throw err;
}
}
async kill(cs) {
super.kill(cs);
this.logger.debug('Cognigy:kill');
this.removeAllListeners();
this.transcribeTask && this.transcribeTask.kill();
this.client.removeAllListeners();
if (this.client && this.client.connected) this.client.disconnect();
if (!this.hasReportedFinalAction) {
this.reportedFinalAction = true;
this.performAction({cognigyResult: 'caller hungup'})
.catch((err) => this.logger.info({err}, 'cognigy - error w/ action webook'));
}
if (this.ep.connected) {
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
}
this.notifyTaskDone();
}
_makeTranscribeTask() {
const opts = {
recognizer: this.data.recognizer || {
vendor: 'default',
language: 'default',
outputFormat: 'detailed'
}
};
this.logger.debug({opts}, 'constructing a nested transcribe object');
const transcribe = makeTask(this.logger, {transcribe: opts}, this);
return transcribe;
}
_makeSayTask(text) {
const opts = {
text,
synthesizer: this.data.synthesizer ||
{
vendor: 'default',
language: 'default',
voice: 'default'
}
};
this.logger.debug({opts}, 'constructing a nested say object');
const say = makeTask(this.logger, {say: opts}, this);
return say;
}
async _onBotError(cs, ep, evt) {
this.logger.info({evt}, 'Cognigy:_onBotError');
this.performAction({cognigyResult: 'botError', message: evt.message });
this.reportedFinalAction = true;
this.notifyTaskDone();
}
async _onBotTypingStatus(cs, ep, evt) {
this.logger.info({evt}, 'Cognigy:_onBotTypingStatus');
}
async _onBotFinalPing(cs, ep) {
this.logger.info('Cognigy:_onBotFinalPing');
if (this.prompts.length) {
const text = this.prompts.join('.');
this.prompts = [];
if (text && !this.killed) {
this.sayTask = this._makeSayTask(text);
this.sayTask.exec(cs, ep, this)
.catch((err) => {
this.logger.info({err}, 'Cognigy say task returned error');
this.notifyTaskDone();
});
}
}
}
async _onBotUtterance(cs, ep, evt) {
this.logger.debug({evt}, 'Cognigy:_onBotUtterance');
if (this.eventHook) {
this.performHook(cs, this.eventHook, {event: 'botMessage', message: evt})
.then((redirected) => {
if (redirected) {
this.logger.info('Cognigy_onTranscription: event handler for bot message redirected us to new webhook');
this.reportedFinalAction = true;
this.performAction({cognigyResult: 'redirect'}, false);
}
return;
})
.catch(({err}) => {
this.logger.info({err}, 'Cognigy_onTranscription: error sending event hook');
});
}
const text = parseBotText(evt);
this.prompts.push(text);
}
async _onTranscription(cs, ep, evt) {
this.logger.debug({evt}, `Cognigy: got transcription for callSid ${cs.callSid}`);
const utterance = evt.alternatives[0].transcript;
if (this.eventHook) {
this.performHook(cs, this.eventHook, {event: 'userMessage', message: utterance})
.then((redirected) => {
if (redirected) {
this.logger.info('Cognigy_onTranscription: event handler for user message redirected us to new webhook');
this.reportedFinalAction = true;
this.performAction({cognigyResult: 'redirect'}, false);
if (this.transcribeTask) this.transcribeTask.kill(cs);
}
return;
})
.catch(({err}) => {
this.logger.info({err}, 'Cognigy_onTranscription: error sending event hook');
});
}
/* send the user utterance to the bot */
try {
if (this.client && this.client.connected) {
this.client.sendMessage(utterance);
}
else {
this.logger.info('Cognigy_onTranscription - not sending user utterance as bot is disconnected');
}
} catch (err) {
this.logger.error({err}, 'Cognigy_onTranscription: Error sending user utterance to Cognigy - ending task');
this.performAction({cognigyResult: 'socketError'});
this.reportedFinalAction = true;
this.notifyTaskDone();
}
}
_onError(cs, ep, err) {
this.logger.debug({err}, 'Cognigy: got error');
if (!this.hasReportedFinalAction) this.performAction({cognigyResult: 'error', err});
this.reportedFinalAction = true;
this.notifyTaskDone();
}
}
module.exports = Cognigy;

View File

@@ -10,6 +10,7 @@ const WAIT = 'wait';
const JOIN = 'join';
const START = 'start';
function confNoMatch(str) {
return str.match(/^No active conferences/) || str.match(/Conference.*not found/);
}
@@ -27,7 +28,8 @@ 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({evt}, `unhandled conference event: ${evt.getHeader('Action')}`) ;
logger.debug(`unhandled conference event: ${evt.getHeader('Action')}`) ;
}
function capitalize(s) {
@@ -45,10 +47,10 @@ class Conference extends Task {
this.confName = this.data.name;
[
'beep', 'startConferenceOnEnter', 'endConferenceOnExit',
'beep', 'startConferenceOnEnter', 'endConferenceOnExit', 'joinMuted',
'maxParticipants', 'waitHook', 'statusHook', 'endHook', 'enterHook'
].forEach((attr) => this[attr] = this.data[attr]);
this.record = this.data.record || {};
this.statusEvents = [];
if (this.statusHook) {
['start', 'end', 'join', 'leave', 'start-talking', 'stop-talking'].forEach((e) => {
@@ -67,6 +69,9 @@ class Conference extends Task {
get name() { return TaskName.Conference; }
get shouldRecord() { return this.record.path; }
get isRecording() { return this.recordingInProgress; }
async exec(cs, ep) {
await super.exec(cs);
this.ep = ep;
@@ -213,6 +218,7 @@ class Conference extends Task {
this._playSession.kill();
this._playSession = null;
}
cs.clearConferenceDetails();
resolve();
});
@@ -330,15 +336,30 @@ class Conference extends Task {
const opts = {};
if (this.endConferenceOnExit) Object.assign(opts, {flags: {endconf: true}});
if (this.startConferenceOnEnter) Object.assign(opts, {flags: {moderator: true}});
if (this.joinMuted) Object.assign(opts, {flags: {mute: true}});
try {
const {memberId, confUuid} = await this.ep.join(this.confName, opts);
this.logger.debug({memberId, confUuid}, `Conference:_joinConference: successfully joined ${this.confName}`);
this.memberId = memberId;
this.confUuid = confUuid;
cs.setConferenceDetails(memberId, this.confName, confUuid);
const response = await this.ep.api('conference', [this.confName, 'get', 'count']);
if (response.body && /\d+/.test(response.body)) this.participantCount = parseInt(response.body);
this._notifyConferenceEvent(cs, 'join');
// start recording if requested and we just started the conference
if (startConf && this.shouldRecord) {
this.logger.info(`recording conference to ${this.record.path}`);
try {
await this.ep.api(`conference ${this.confName} record ${this.record.path}`);
} catch (err) {
this.logger.info({err}, 'Conference:_joinConference - failed to start recording');
}
}
// listen for conference events
this.ep.filter('Conference-Unique-ID', this.confUuid);
this.ep.conn.on('esl::event::CUSTOM::*', this.__onConferenceEvent.bind(this, cs)) ;
@@ -371,9 +392,70 @@ class Conference extends Task {
*/
notifyStartConference(cs, opts) {
this.logger.info({opts}, `Conference:notifyStartConference: conference ${this.confName} has now started`);
this.conferenceStartTime = new Date();
this.emitter.emit('join', opts);
}
async doConferenceMuteNonModerators(cs, opts) {
const mute = opts.conf_mute_status === 'mute';
assert (cs.isInConference);
this.logger.info(`Conference:doConferenceMuteNonModerators ${mute ? 'muting' : 'unmuting'} non-moderators`);
this.ep.api(`conference ${this.confName} ${mute ? 'mute' : 'unmute'} non_moderator`)
.catch((err) => this.logger.info({err}, 'Error muting or unmuting non_moderators'));
if (this.conf_hold_status !== 'hold' && this._playSession) {
this._playSession.kill();
this._playSession = null;
}
}
async doConferenceHold(cs, opts) {
assert (cs.isInConference);
const {conf_hold_status, wait_hook} = opts;
let hookOnly = true;
if (this.conf_hold_status !== conf_hold_status) {
hookOnly = false;
this.conf_hold_status = conf_hold_status;
const hold = conf_hold_status === 'hold';
this.ep.api(`conference ${this.confName} ${hold ? 'mute' : 'unmute'} ${this.memberId}`)
.catch((err) => this.logger.info({err}, 'Error muting or unmuting participant'));
this.ep.api(`conference ${this.confName} ${hold ? 'deaf' : 'undeaf'} ${this.memberId}`)
.catch((err) => this.logger.info({err}, 'Error deafing or undeafing participant'));
}
if (hookOnly && this._playSession) {
this._playSession.kill();
this._playSession = null;
}
if (wait_hook && this.conf_hold_status === 'hold') {
const {dlg} = cs;
this._doWaitHookWhileOnHold(cs, dlg, wait_hook);
}
else if (this.conf_hold_status !== 'hold' && this._playSession) {
this._playSession.kill();
this._playSession = null;
}
}
async _doWaitHookWhileOnHold(cs, dlg, wait_hook) {
do {
try {
const tasks = await this._playHook(cs, dlg, wait_hook);
if (0 === tasks.length) break;
} catch (err) {
if (!this.killed) {
this.logger.info(err, `Conference:_doWait: failed retrieving wait_hook for ${this.confName}`);
}
this._playSession = null;
break;
}
} while (!this.killed && this.conf_hold_status !== 'hold');
}
/**
* Add ourselves to the waitlist of sessions to be notified once
* the conference starts
@@ -464,6 +546,9 @@ class Conference extends Task {
dlg,
ep: cs.ep,
callInfo: cs.callInfo,
accountInfo: cs.accountInfo,
memberId: this.memberId,
confName: this.confName,
tasks
});
await this._playSession.exec();
@@ -484,6 +569,7 @@ class Conference extends Task {
}
async replaceEndpointAndEnd(cs) {
cs.clearConferenceDetails();
if (this.replaced) return;
this.replaced = true;
try {

View File

@@ -12,6 +12,7 @@ const assert = require('assert');
const placeCall = require('../utils/place-outdial');
const sessionTracker = require('../session/session-tracker');
const DtmfCollector = require('../utils/dtmf-collector');
const dbUtils = require('../utils/db-utils');
const debug = require('debug')('jambonz:feature-server');
function parseDtmfOptions(logger, dtmfCapture) {
@@ -130,6 +131,10 @@ class TaskDial extends Task {
get name() { return TaskName.Dial; }
get canReleaseMedia() {
return !process.env.ANCHOR_MEDIA_ALWAYS && !this.listenTask && !this.transcribeTask;
}
async exec(cs) {
await super.exec(cs);
try {
@@ -142,13 +147,12 @@ class TaskDial extends Task {
this.epOther.play(this.dialMusic).catch((err) => {});
}
}
if (this.epOther) this._installDtmfDetection(cs, this.epOther, this.parentDtmfCollector);
await this._attemptCalls(cs);
if (!this.killed) await this._attemptCalls(cs);
await this.awaitTaskDone();
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);
this._removeDtmfDetection(cs.dlg);
this._removeDtmfDetection(this.dlg);
} catch (err) {
this.logger.error({err}, 'TaskDial:exec terminating with error');
this.kill(cs);
@@ -157,18 +161,30 @@ class TaskDial extends Task {
async kill(cs, reason) {
super.kill(cs);
if (this.dialMusic && this.epOther) {
this.epOther.api('uuid_break', this.epOther.uuid)
.catch((err) => this.logger.info(err, 'Error killing dialMusic'));
}
this.killReason = reason || KillReason.Hangup;
this._removeDtmfDetection(this.cs, this.epOther);
this._removeDtmfDetection(this.cs, this.ep);
if (this.timerMaxCallDuration) {
clearTimeout(this.timerMaxCallDuration);
this.timerMaxCallDuration = null;
}
if (this.timerRing) {
clearTimeout(this.timerRing);
this.timerRing = null;
}
this._removeDtmfDetection(cs.dlg);
this._removeDtmfDetection(this.dlg);
this._killOutdials();
if (this.sd) {
this.sd.kill();
this.sd.removeAllListeners();
this.sd = null;
}
if (this.callSid) sessionTracker.remove(this.callSid);
if (this.listenTask) await this.listenTask.kill(cs);
if (this.transcribeTask) await this.transcribeTask.kill(cs);
if (this.timerMaxCallDuration) clearTimeout(this.timerMaxCallDuration);
this.notifyTaskDone();
}
@@ -177,9 +193,14 @@ class TaskDial extends Task {
* @param {*} tasks - array of play/say tasks to execute
*/
async whisper(tasks, callSid) {
if (!this.epOther || !this.ep) return this.logger.info('Dial:whisper: no paired endpoint found');
try {
const cs = this.callSession;
if (!this.ep && !this.epOther) {
await this.reAnchorMedia(this.callSession, this.sd);
}
if (!this.epOther || !this.ep) return this.logger.info('Dial:whisper: no paired endpoint found');
this.logger.debug('Dial:whisper unbridging endpoints');
await this.epOther.unbridge();
this.logger.debug('Dial:whisper executing tasks');
@@ -188,7 +209,12 @@ class TaskDial extends Task {
await task.exec(cs, callSid === this.callSid ? this.ep : this.epOther);
}
this.logger.debug('Dial:whisper tasks complete');
if (!cs.callGone && this.epOther) this.epOther.bridge(this.ep);
if (!cs.callGone && this.epOther) {
/* if we can release the media back to the SBC, do so now */
if (this.canReleaseMedia) this._releaseMedia(cs, this.sd);
else this.epOther.bridge(this.ep);
}
} catch (err) {
this.logger.error(err, 'Dial:whisper error');
}
@@ -198,54 +224,72 @@ class TaskDial extends Task {
* mute or unmute one side of the call
*/
async mute(callSid, doMute) {
if (!this.epOther || !this.ep) return this.logger.info('Dial:mute: no paired endpoint found');
const parentCall = callSid !== this.callSid;
const dlg = parentCall ? this.callSession.dlg : this.dlg;
const hdr = `${doMute ? 'mute' : 'unmute'} call leg`;
try {
const parentCall = callSid !== this.callSid;
const ep = parentCall ? this.epOther : this.ep;
await ep[doMute ? 'mute' : 'unmute']();
this.logger.debug(`Dial:mute ${doMute ? 'muted' : 'unmuted'} ${parentCall ? 'parentCall' : 'childCall'}`);
/* let rtpengine do the mute / unmute */
await dlg.request({
method: 'INFO',
headers: {
'X-Reason': hdr
}
});
} catch (err) {
this.logger.error(err, 'Dial:mute error');
this.logger.info({err}, `Dial:mute - ${hdr} error`);
}
}
_removeHandlers(sd) {
sd.removeAllListeners('accept');
sd.removeAllListeners('decline');
sd.removeAllListeners('adulting');
sd.removeAllListeners('callStatusChange');
sd.removeAllListeners('callCreateFail');
}
_killOutdials() {
for (const [callSid, sd] of Array.from(this.dials)) {
this.logger.debug(`Dial:_killOutdials killing callSid ${callSid}`);
sd.kill().catch((err) => this.logger.info(err, `Dial:_killOutdials Error killing ${callSid}`));
this._removeHandlers(sd);
}
this.dials.clear();
}
_installDtmfDetection(cs, ep, dtmfDetector) {
if (ep && this.dtmfHook && !ep.dtmfDetector) {
ep.dtmfDetector = dtmfDetector;
ep.on('dtmf', this._onDtmf.bind(this, cs, ep));
}
_installDtmfDetection(cs, dlg) {
dlg.on('info', this._onInfo.bind(this, cs, dlg));
}
_removeDtmfDetection(cs, ep) {
if (ep) {
delete ep.dtmfDetector;
ep.removeAllListeners('dtmf');
}
_removeDtmfDetection(dlg) {
dlg && dlg.removeAllListeners('info');
}
_onDtmf(cs, ep, evt) {
if (ep.dtmfDetector) {
const match = ep.dtmfDetector.keyPress(evt.dtmf);
if (match) {
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, {dtmf: match, ...cs.callInfo})
.catch((err) => this.logger.info(err, 'Dial:_onDtmf - error'));
}
}
_onInfo(cs, dlg, req, res) {
res.send(200);
if (req.get('Content-Type') !== 'application/dtmf-relay') return;
const dtmfDetector = dlg === cs.dlg ? this.parentDtmfCollector : this.childDtmfCollector;
if (!dtmfDetector) return;
let requestor, callSid, callInfo;
if (dtmfDetector === this.parentDtmfCollector) {
requestor = cs.requestor;
callSid = cs.callSid;
callInfo = cs.callInfo;
}
else {
requestor = this.sd?.requestor;
callSid = this.sd?.callSid;
callInfo = this.sd?.callInfo;
}
if (!requestor) return;
const arr = /Signal=([0-9#*])/.exec(req.body);
if (!arr) return;
const key = arr[1];
const match = dtmfDetector.keyPress(key);
if (match) {
this.logger.info({callSid}, `Dial:_onInfo triggered dtmf match: ${match}`);
requestor.request(this.dtmfHook, {dtmf: match, ...callInfo.toJSON()})
.catch((err) => this.logger.info(err, 'Dial:_onDtmf - error'));
}
}
@@ -255,7 +299,7 @@ class TaskDial extends Task {
debug(`Dial:__initializeInbound allocated ep for incoming call: ${ep.uuid}`);
/* send outbound legs back to the same SBC (to support static IP feature) */
if (!this.proxy) this.proxy = `${cs.req.source_address}:${cs.req.source_port};transport=tcp`;
if (!this.proxy) this.proxy = `${cs.req.source_address}:${cs.req.source_port}`;
if (this.dialMusic) {
// play dial music to caller while we outdial
@@ -269,6 +313,7 @@ class TaskDial extends Task {
const {req, srf} = cs;
const {getSBC} = srf.locals;
const {lookupTeamsByAccount, lookupAccountBySid} = srf.locals.dbHelpers;
const {lookupCarrier} = dbUtils(this.logger, cs.srf);
const sbcAddress = this.proxy || getSBC();
const teamsInfo = {};
let fqdn;
@@ -292,8 +337,9 @@ class TaskDial extends Task {
}
const ms = await cs.getMS();
const timerRing = setTimeout(() => {
this.timerRing = setTimeout(() => {
this.logger.info(`Dial:_attemptCall: ring no answer timer ${this.timeout}s exceeded`);
this.timerRing = null;
this._killOutdials();
}, this.timeout * 1000);
@@ -314,6 +360,16 @@ class TaskDial extends Task {
this.logger.error({err}, 'Error looking up account by sid');
}
}
if (t.type === 'phone' && t.trunk) {
const voip_carrier_sid = await lookupCarrier(cs.accountSid, t.trunk);
this.logger.info(`Dial:_attemptCalls: selected ${voip_carrier_sid} for requested carrier: ${t.trunk})`);
if (voip_carrier_sid) {
opts.headers['X-Requested-Carrier-Sid'] = voip_carrier_sid;
}
}
if (this.killed) return;
const sd = placeCall({
logger: this.logger,
application: cs.application,
@@ -322,13 +378,16 @@ class TaskDial extends Task {
sbcAddress,
target: t,
opts,
callInfo: cs.callInfo
callInfo: cs.callInfo,
accountInfo: cs.accountInfo
});
this.dials.set(sd.callSid, sd);
sd
.on('callCreateFail', () => {
clearTimeout(this.timerRing);
this.dials.delete(sd.callSid);
sd.removeAllListeners();
if (this.dials.size === 0 && !this.sd) {
this.logger.debug('Dial:_attemptCalls - all calls failed after call create err, ending task');
this.kill(cs);
@@ -352,7 +411,8 @@ class TaskDial extends Task {
break;
case CallStatus.InProgress:
this.logger.debug('Dial:_attemptCall -- call was answered');
clearTimeout(timerRing);
clearTimeout(this.timerRing);
this.timerRing = null;
break;
case CallStatus.Failed:
case CallStatus.Busy:
@@ -360,24 +420,39 @@ class TaskDial extends Task {
this.dials.delete(sd.callSid);
if (this.dials.size === 0 && !this.sd) {
this.logger.debug('Dial:_attemptCalls - all calls failed after call failure, ending task');
clearTimeout(timerRing);
clearTimeout(this.timerRing);
this.timerRing = null;
this.kill(cs);
}
break;
}
})
.on('accept', () => {
.on('accept', async() => {
this.logger.debug(`Dial:_attemptCalls - we have a winner: ${sd.callSid}`);
this._connectSingleDial(cs, sd);
clearTimeout(this.timerRing);
try {
await this._connectSingleDial(cs, sd);
} catch (err) {
this.logger.info({err}, 'Dial:_attemptCalls - Error calling _connectSingleDial ');
}
})
.on('decline', () => {
this.logger.debug(`Dial:_attemptCalls - declined: ${sd.callSid}`);
clearTimeout(this.timerRing);
this.dials.delete(sd.callSid);
sd.removeAllListeners();
if (this.dials.size === 0 && !this.sd) {
this.logger.debug('Dial:_attemptCalls - all calls failed after decline, ending task');
this.kill(cs);
}
})
.on('reinvite', (req, res) => {
try {
cs.handleReinviteAfterMediaReleased(req, res);
} catch (err) {
this.logger.error(err, 'Error in dial einvite from B leg');
}
})
.once('adulting', () => {
/* child call just adulted and got its own session */
this.logger.info('Dial:on_adulting: detaching child call leg');
@@ -394,8 +469,8 @@ class TaskDial extends Task {
});
}
_connectSingleDial(cs, sd) {
if (!this.bridged) {
async _connectSingleDial(cs, sd) {
if (!this.bridged && !this.canReleaseMedia) {
this.logger.debug('Dial:_connectSingleDial bridging endpoints');
if (this.epOther) {
this.epOther.api('uuid_break', this.epOther.uuid);
@@ -405,10 +480,16 @@ class TaskDial extends Task {
}
// ding! ding! ding! we have a winner
this._selectSingleDial(cs, sd);
await this._selectSingleDial(cs, sd);
this._killOutdials(); // NB: order is important
}
_onMaxCallDuration(cs) {
this.logger.info(`Dial:_onMaxCallDuration tearing down call as it has reached ${this.timeLimit}s`);
this.ep && this.ep.unbridge();
this.kill(cs);
}
/**
* We now have a call leg produced by the Dial action, so
* - hangup any simrings in progress
@@ -418,7 +499,7 @@ class TaskDial extends Task {
* - launch any nested tasks
* - and establish a handler to clean up if the called party hangs up
*/
_selectSingleDial(cs, sd) {
async _selectSingleDial(cs, sd) {
debug(`Dial:_selectSingleDial ep for outbound call: ${sd.ep.uuid}`);
this.dials.delete(sd.callSid);
@@ -426,14 +507,10 @@ class TaskDial extends Task {
this.callSid = sd.callSid;
if (this.earlyMedia) {
debug('Dial:_selectSingleDial propagating answer supervision on A leg now that B is connected');
cs.propagateAnswer();
await cs.propagateAnswer();
}
if (this.timeLimit) {
this.timerMaxCallDuration = setTimeout(() => {
this.logger.info(`Dial:_selectSingleDial tearing down call as it has reached ${this.timeLimit}s`);
this.ep.unbridge();
this.kill(cs);
}, this.timeLimit * 1000);
this.timerMaxCallDuration = setTimeout(this._onMaxCallDuration.bind(this, cs), this.timeLimit * 1000);
}
sessionTracker.add(this.callSid, cs);
this.dlg.on('destroy', () => {
@@ -441,8 +518,11 @@ class TaskDial extends Task {
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();
if (this.timerMaxCallDuration) {
clearTimeout(this.timerMaxCallDuration);
this.timerMaxCallDuration = null;
}
this.ep && this.ep.unbridge();
this.kill(cs);
}
});
@@ -453,10 +533,14 @@ class TaskDial extends Task {
dialCallSid: sd.callSid,
});
if (this.childDtmfCollector) this._installDtmfDetection(cs, this.ep, this.childDtmfCollector);
if (this.parentDtmfCollector) this._installDtmfDetection(cs, cs.dlg);
if (this.childDtmfCollector) this._installDtmfDetection(cs, this.dlg);
if (this.transcribeTask) this.transcribeTask.exec(cs, this.ep);
if (this.listenTask) this.listenTask.exec(cs, this.ep);
/* if we can release the media back to the SBC, do so now */
if (this.canReleaseMedia) this._releaseMedia(cs, sd);
}
_bridgeEarlyMedia(sd) {
@@ -468,6 +552,38 @@ class TaskDial extends Task {
}
}
/**
* Release the media from freeswitch
* @param {*} cs
* @param {*} sd
*/
async _releaseMedia(cs, sd) {
assert(cs.ep && sd.ep);
try {
const aLegSdp = cs.ep.remote.sdp;
const bLegSdp = sd.dlg.remote.sdp;
await Promise.all[sd.releaseMediaToSBC(aLegSdp, cs.ep.local.sdp), cs.releaseMediaToSBC(bLegSdp)];
this.epOther = null;
this.logger.info('Dial:_releaseMedia - successfully released media from freewitch');
} catch (err) {
this.logger.info({err}, 'Dial:_releaseMedia error');
}
}
async reAnchorMedia(cs, sd) {
if (cs.ep && sd.ep) return;
this.logger.info('Dial:reAnchorMedia - re-anchoring media to freewitch');
await Promise.all([sd.reAnchorMedia(), cs.reAnchorMedia()]);
this.epOther = cs.ep;
}
async handleReinviteAfterMediaReleased(req, res) {
const sdp = await this.dlg.modify(req.body);
this.logger.info({sdp}, 'Dial:handleReinviteAfterMediaReleased - sent reinvite to B leg');
res.send(200, {body: sdp});
}
}
module.exports = TaskDial;

View File

@@ -9,10 +9,22 @@ class Dialogflow extends Task {
constructor(logger, opts) {
super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint;
this.credentials = this.data.credentials;
if (this.data.environment) this.project = `${this.data.project}:${this.data.environment}`;
else this.project = this.data.project;
/* set project id with environment and region (optionally) */
if (this.data.environment && this.data.region) {
this.project = `${this.data.project}:${this.data.environment}:${this.data.region}`;
}
else if (this.data.environment) {
this.project = `${this.data.project}:${this.data.environment}`;
}
else if (this.data.region) {
this.project = `${this.data.project}::${this.data.region}`;
}
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') {
@@ -198,6 +210,7 @@ class Dialogflow extends Task {
/* 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 {stats} = srf.locals;
const {synthAudio} = srf.locals.dbHelpers;
this.waitingForPlayStart = false;
@@ -217,7 +230,7 @@ class Dialogflow extends Task {
credentials: this.ttsCredentials
};
this.logger.debug({obj}, 'Dialogflow:_onIntent - playing message via tts');
const {filePath, servedFromCache} = await synthAudio(obj);
const {filePath, servedFromCache} = await synthAudio(stats, obj);
if (filePath) cs.trackTmpFile(filePath);
if (!this.ttsCredentials && !servedFromCache) cs.billForTts(intent.fulfillmentText.length);
@@ -439,8 +452,8 @@ class Dialogflow extends Task {
this.noinputTimer = setTimeout(this._onNoInput.bind(this, ep, cs), this.noInputTimeout);
}
async _performHook(cs, hook, results) {
const json = await this.cs.requestor.request(hook, results);
async _performHook(cs, hook, results = {}) {
const json = await this.cs.requestor.request(hook, {...results, ...cs.callInfo.toJSON()});
if (json && Array.isArray(json)) {
const makeTask = require('../make_task');
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));

View File

@@ -79,12 +79,14 @@ class TaskEnqueue extends Task {
this.notifyUrl = url;
/* invoke account-level webhook for queue event notifications */
cs.performQueueWebhook({
event: 'join',
queue: this.data.name,
length: members,
joinTime: this.waitStartTime
});
try {
cs.performQueueWebhook({
event: 'join',
queue: this.data.name,
length: members,
joinTime: this.waitStartTime
});
} catch (err) {}
}
async _removeFromQueue(cs) {
@@ -114,6 +116,7 @@ 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;
}
@@ -233,6 +236,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');
@@ -325,16 +329,15 @@ class TaskEnqueue extends Task {
// check for 'leave' verb and only execute tasks up till then
const tasksToRun = [];
let leave = false;
for (const o of tasks) {
if (o.name === TaskName.Leave) {
leave = true;
this._leave = true;
this.logger.info('waitHook returned a leave task');
break;
}
tasksToRun.push(o);
}
const cloneTasks = [...tasksToRun];
if (this.killed) return [];
else if (tasksToRun.length > 0) {
this._playSession = new ConfirmCallSession({
@@ -343,16 +346,17 @@ class TaskEnqueue extends Task {
dlg,
ep: cs.ep,
callInfo: cs.callInfo,
accountInfo: cs.accountInfo,
tasks: tasksToRun
});
await this._playSession.exec();
this._playSession = null;
}
if (leave) {
if (this._leave) {
this.state = QueueResults.Leave;
this.kill(cs);
}
return tasksToRun;
return cloneTasks;
}
}

View File

@@ -3,14 +3,15 @@ const {
TaskName,
TaskPreconditions,
GoogleTranscriptionEvents,
AwsTranscriptionEvents
AwsTranscriptionEvents,
AzureTranscriptionEvents
} = require('../utils/constants');
const makeTask = require('./make_task');
const assert = require('assert');
class TaskGather extends Task {
constructor(logger, opts) {
constructor(logger, opts, parentTask) {
super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint;
@@ -29,10 +30,20 @@ class TaskGather extends Task {
this.hints = recognizer.hints || [];
this.altLanguages = recognizer.altLanguages || [];
/* vad: if provided, we dont connect to recognizer until voice activity is detected */
const {enable, voiceMs = 0, mode = -1} = recognizer.vad || {};
this.vad = {enable, voiceMs, mode};
/* aws options */
this.vocabularyName = recognizer.vocabularyName;
this.vocabularyFilterName = recognizer.vocabularyFilterName;
this.filterMethod = recognizer.filterMethod;
/* microsoft options */
this.outputFormat = recognizer.outputFormat || 'simple';
this.profanityOption = recognizer.profanityOption || 'raw';
this.requestSnr = recognizer.requestSnr || false;
this.initialSpeechTimeoutMs = recognizer.initialSpeechTimeoutMs || 0;
}
this.digitBuffer = '';
@@ -40,10 +51,14 @@ class TaskGather extends Task {
if (this.say) this.sayTask = makeTask(this.logger, {say: this.say}, this);
if (this.play) this.playTask = makeTask(this.logger, {play: this.play}, this);
this.parentTask = parentTask;
}
get name() { return TaskName.Gather; }
get needsStt() { return this.input.includes('speech'); }
get earlyMedia() {
return (this.sayTask && this.sayTask.earlyMedia) ||
(this.playTask && this.playTask.earlyMedia);
@@ -57,9 +72,9 @@ class TaskGather extends Task {
if ('default' === this.vendor || !this.vendor) this.vendor = cs.speechRecognizerVendor;
if ('default' === this.language || !this.language) this.language = cs.speechRecognizerLanguage;
this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt');
if (!this.sttCredentials) {
if (this.needsStt && !this.sttCredentials) {
const {writeAlerts, AlertType} = cs.srf.locals;
this.logger.info(`TaskGather:exec - ERROR stt using ${this.vendor} requested but not creds supplied`);
this.logger.info(`TaskGather:exec - ERROR stt using ${this.vendor} requested but creds not supplied`);
writeAlerts({
account_sid: cs.accountSid,
alert_type: AlertType.STT_NOT_PROVISIONED,
@@ -92,7 +107,7 @@ class TaskGather extends Task {
}
if (this.input.includes('digits')) {
ep.on('dtmf', this._onDtmf.bind(this, ep));
ep.on('dtmf', this._onDtmf.bind(this, cs, ep));
}
await this.awaitTaskDone();
@@ -102,28 +117,36 @@ class TaskGather extends Task {
ep.removeCustomEventListener(GoogleTranscriptionEvents.Transcription);
ep.removeCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance);
ep.removeCustomEventListener(AwsTranscriptionEvents.Transcription);
ep.removeCustomEventListener(AzureTranscriptionEvents.Transcription);
ep.removeCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected);
}
kill(cs) {
super.kill(cs);
this._killAudio();
this._killAudio(cs);
this.ep.removeAllListeners('dtmf');
this._resolve('killed');
}
_onDtmf(ep, evt) {
_onDtmf(cs, ep, evt) {
this.logger.debug(evt, 'TaskGather:_onDtmf');
if (evt.dtmf === this.finishOnKey) this._resolve('dtmf-terminator-key');
else {
this.digitBuffer += evt.dtmf;
if (this.digitBuffer.length === this.numDigits) this._resolve('dtmf-num-digits');
}
this._killAudio();
this._killAudio(cs);
}
async _initSpeech(cs, ep) {
const opts = {};
if (this.vad.enable) {
opts.START_RECOGNIZING_ON_VAD = 1;
if (this.vad.voiceMs) opts.RECOGNIZER_VAD_VOICE_MS = this.vad.voiceMs;
if (this.vad.mode >= 0 && this.vad.mode <= 3) opts.RECOGNIZER_VAD_MODE = this.vad.mode;
}
if ('google' === this.vendor) {
if (this.sttCredentials) opts.GOOGLE_APPLICATION_CREDENTIALS = JSON.stringify(this.sttCredentials.credentials);
Object.assign(opts, {
@@ -131,7 +154,9 @@ class TaskGather extends Task {
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.hints && this.hints.length > 1) {
opts.GOOGLE_SPEECH_HINTS = this.hints.map((h) => h.trim()).join(',');
}
if (this.altLanguages && this.altLanguages.length > 1) {
opts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
}
@@ -141,23 +166,41 @@ class TaskGather extends Task {
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance, this._onEndOfUtterance.bind(this, cs, ep));
}
else {
else if (['aws', 'polly'].includes(this.vendor)) {
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: this.sttCredentials.accessKeyId,
AWS_SECRET_ACCESS_KEY: this.sttCredentials.secretAccessKey,
AWS_REGION: this.sttCredentials.region
});
if (this.sttCredentials) {
Object.assign(opts, {
AWS_ACCESS_KEY_ID: this.sttCredentials.accessKeyId,
AWS_SECRET_ACCESS_KEY: this.sttCredentials.secretAccessKey,
AWS_REGION: this.sttCredentials.region
});
}
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
}
this.logger.debug({vars: opts}, 'setting freeswitch vars');
else if ('microsoft' === this.vendor) {
if (this.sttCredentials) {
Object.assign(opts, {
'AZURE_SUBSCRIPTION_KEY': this.sttCredentials.api_key,
'AZURE_REGION': this.sttCredentials.region
});
}
if (this.hints && this.hints.length > 1) {
opts.AZURE_SPEECH_HINTS = this.hints.map((h) => h.trim()).join(',');
}
//if (this.requestSnr) opts.AZURE_REQUEST_SNR = 1;
//if (this.profanityOption !== 'raw') opts.AZURE_PROFANITY_OPTION = this.profanityOption;
if (this.initialSpeechTimeoutMs > 0) opts.AZURE_INITIAL_SPEECH_TIMEOUT_MS = this.initialSpeechTimeoutMs;
opts.AZURE_USE_OUTPUT_FORMAT_DETAILED = 1;
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected, this._onNoSpeechDetected.bind(this, cs, ep));
}
await ep.set(opts)
.catch((err) => this.logger.info(err, 'Error setting channel variables'));
}
_startTranscribing(ep) {
@@ -190,24 +233,36 @@ class TaskGather extends Task {
}
}
_killAudio() {
_killAudio(cs) {
if (this.sayTask && !this.sayTask.killed) {
this.sayTask.removeAllListeners('playDone');
this.sayTask.kill();
this.sayTask.kill(cs);
this.sayTask = null;
}
if (this.playTask && !this.playTask.killed) {
this.playTask.removeAllListeners('playDone');
this.playTask.kill();
this.playTask.kill(cs);
this.playTask = null;
}
}
_onTranscription(cs, ep, evt) {
if ('aws' === this.vendor && Array.isArray(evt) && evt.length > 0) evt = evt[0];
this.logger.debug(evt, 'TaskGather:_onTranscription');
const final = evt.is_final;
if (final) {
this._resolve('speech', evt);
if ('microsoft' === this.vendor) {
const nbest = evt.NBest;
const newEvent = {
is_final: evt.RecognitionStatus === 'Success',
alternatives: [
{
confidence: nbest[0].Confidence,
transcript: nbest[0].Display
}
]
};
evt = newEvent;
}
this.logger.debug(evt, 'TaskGather:_onTranscription');
if (evt.is_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'));
@@ -220,6 +275,10 @@ class TaskGather extends Task {
}
}
_onNoSpeechDetected(cs, ep) {
this._resolve('timeout');
}
async _resolve(reason, evt) {
if (this.resolved) return;
this.resolved = true;
@@ -232,10 +291,15 @@ class TaskGather extends Task {
this._clearTimer();
if (reason.startsWith('dtmf')) {
await this.performAction({digits: this.digitBuffer});
await this.performAction({digits: this.digitBuffer, reason: 'dtmfDetected'});
}
else if (reason.startsWith('speech')) {
await this.performAction({speech: evt});
if (this.parentTask) this.parentTask.emit('transcription', evt);
else await this.performAction({speech: evt, reason: 'speechDetected'});
}
else if (reason.startsWith('timeout')) {
if (this.parentTask) this.parentTask.emit('timeout', evt);
else await this.performAction({reason: 'timeout'});
}
this.notifyTaskDone();
}

View File

@@ -182,12 +182,13 @@ class Lex extends Task {
const type = messages[0].type;
if (['PlainText', 'SSML'].includes(type) && msg) {
const {srf} = cs;
const {stats} = srf.locals;
const {synthAudio} = srf.locals.dbHelpers;
try {
this.logger.debug(`tts with ${this.vendor} ${this.voice}`);
// eslint-disable-next-line no-unused-vars
const {filePath, servedFromCache} = await synthAudio({
const {filePath, servedFromCache} = await synthAudio(stats, {
text: msg,
vendor: this.vendor,
language: this.language,

View File

@@ -64,6 +64,7 @@ class TaskListen extends Task {
this.results.dialCallDuration = duration;
}
if (this.transcribeTask) await this.transcribeTask.kill(cs);
this.ep && this._removeListeners(this.ep);
this.notifyTaskDone();
}
@@ -122,6 +123,11 @@ class TaskListen extends Task {
if (this.finishOnKey || this.passDtmf) {
ep.on('dtmf', this._dtmfHandler);
}
/* support bi-directional audio */
ep.addCustomEventListener(ListenEvents.PlayAudio, this._onPlayAudio.bind(this, ep));
ep.addCustomEventListener(ListenEvents.KillAudio, this._onKillAudio.bind(this, ep));
ep.addCustomEventListener(ListenEvents.Disconnect, this._onDisconnect.bind(this, ep));
}
_removeListeners(ep) {
@@ -131,6 +137,10 @@ class TaskListen extends Task {
if (this.finishOnKey || this.passDtmf) {
ep.removeListener('dtmf', this._dtmfHandler);
}
ep.removeCustomEventListener(ListenEvents.PlayAudio);
ep.removeCustomEventListener(ListenEvents.KillAudio);
ep.removeCustomEventListener(ListenEvents.Disconnect);
}
_onDtmf(evt) {
@@ -154,6 +164,29 @@ class TaskListen extends Task {
this.logger.info(evt, 'TaskListen:_onConnectFailure');
this.notifyTaskDone();
}
async _onPlayAudio(ep, evt) {
this.logger.info(`received play_audio event: ${JSON.stringify(evt)}`);
try {
const results = await ep.play(evt.file);
this.logger.debug(`Finished playing file, result: ${JSON.stringify(results)}`);
ep.forkAudioSendText({type: 'playDone', data: Object.assign({id: evt.id}, results)});
}
catch (err) {
this.logger.error({err}, 'Error playing file');
}
}
_onKillAudio(ep) {
this.logger.info('received kill_audio event');
ep.api('uuid_break', ep.uuid);
}
_onDisconnect(ep, cs) {
this.logger.debug('_onDisconnect: TaskListen terminating task');
this.kill(cs);
}
_onError(ep, evt) {
this.logger.info(evt, 'TaskListen:_onError');
this.notifyTaskDone();

View File

@@ -17,8 +17,13 @@ function makeTask(logger, obj, parent) {
case TaskName.SipDecline:
const TaskSipDecline = require('./sip_decline');
return new TaskSipDecline(logger, data, parent);
case TaskName.SipRefer:
const TaskSipRefer = require('./sip_refer');
return new TaskSipRefer(logger, data, parent);
case TaskName.Cognigy:
const TaskCognigy = require('./cognigy');
return new TaskCognigy(logger, data, parent);
case TaskName.Conference:
logger.debug({data}, 'Conference verb');
const TaskConference = require('./conference');
return new TaskConference(logger, data, parent);
case TaskName.Dial:
@@ -48,6 +53,9 @@ function makeTask(logger, obj, parent) {
case TaskName.Message:
const TaskMessage = require('./message');
return new TaskMessage(logger, data, parent);
case TaskName.Rasa:
const TaskRasa = require('./rasa');
return new TaskRasa(logger, data, parent);
case TaskName.Say:
const TaskSay = require('./say');
return new TaskSay(logger, data, parent);

View File

@@ -1,6 +1,7 @@
const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants');
const bent = require('bent');
const { v4: uuidv4 } = require('uuid');
class TaskMessage extends Task {
constructor(logger, opts) {
@@ -8,13 +9,11 @@ class TaskMessage extends Task {
this.preconditions = TaskPreconditions.None;
this.payload = {
message_sid: this.data.message_sid,
provider: this.data.provider,
message_sid: this.data.message_sid || uuidv4(),
carrier: this.data.carrier,
to: this.data.to,
from: this.data.from,
cc: this.data.cc,
text: this.data.text,
media: this.data.media
text: this.data.text
};
}
@@ -28,20 +27,22 @@ class TaskMessage extends Task {
const {srf, accountSid} = cs;
const {res} = cs.callInfo;
let payload = this.payload;
const actionParams = {message_sid: this.payload.message_sid};
await super.exec(cs);
try {
const {getSBC, getSmpp, dbHelpers} = srf.locals;
const {getSmpp, dbHelpers} = srf.locals;
const {lookupSmppGateways} = dbHelpers;
this.logger.info(`looking up gateways for account_sid: ${accountSid}`);
this.logger.debug(`looking up gateways for account_sid: ${accountSid}`);
const r = await lookupSmppGateways(accountSid);
let gw, url, relativeUrl;
if (r.length > 0) {
gw = r.find((o) => 1 === o.sg.outbound && (!this.payload.provider || o.vc.name === this.payload.provider));
gw = r.find((o) => 1 === o.sg.outbound && (!this.payload.carrier || o.vc.name === this.payload.carrier));
}
if (gw) {
this.logger.info({gw, accountSid}, 'Message:exec - using smpp to send message');
url = getSmpp();
url = process.env.K8S ? 'http://smpp' : getSmpp();
relativeUrl = '/sms';
payload = {
...payload,
@@ -50,37 +51,75 @@ class TaskMessage extends Task {
};
}
else {
this.logger.info({gw, accountSid, provider: this.payload.provider},
//TMP: smpp only at the moment, need to add http back in
/*
this.logger.info({gw, accountSid, carrier: this.payload.carrier},
'Message:exec - no smpp gateways found to send message');
relativeUrl = 'v1/outboundSMS';
const sbcAddress = getSBC();
if (sbcAddress) url = `http://${sbcAddress}:3000/`;
//TMP: smpp only at the moment, need to add http back in
return res.sendStatus(404);
*/
this.performAction({
...actionParams,
message_status: 'no carriers'
}).catch((err) => {});
if (res) res.sendStatus(404);
return;
}
if (url) {
const post = bent(url, 'POST', 'json', 201);
const post = bent(url, 'POST', 'json', 201, 480);
this.logger.info({payload, url}, 'Message:exec sending outbound SMS');
const response = await post(relativeUrl, 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');
res.status(200).json({
sid: cs.callInfo.messageSid,
providerResponse: response
});
const {smpp_err_code, carrier, message_id, message} = response;
if (smpp_err_code) {
this.logger.info({response}, 'SMPP error sending SMS');
this.performAction({
...actionParams,
carrier,
carrier_message_id: message_id,
message_status: 'failure',
message_failure_reason: message
}).catch((err) => {});
if (res) {
res.status(480).json({
...response,
sid: cs.callInfo.messageSid
});
}
}
else {
const {message_id, carrier} = response;
this.logger.info({response}, 'Successfully sent SMS');
this.performAction({
...actionParams,
carrier,
carrier_message_id: message_id,
message_status: 'success',
}).catch((err) => {});
if (res) {
res.status(200).json({
sid: cs.callInfo.messageSid,
carrierResponse: response
});
}
}
// TODO: action Hook
}
else {
this.logger.info('Message:exec - unable to send SMS as there are no available SMS gateways');
res.status(422).json({message: 'no configured SMS gateways'});
this.logger.info('Message:exec - unable to send SMS as SMPP is not configured on the system');
this.performAction({
...actionParams,
message_status: 'smpp configuration error'
}).catch((err) => {});
if (res) res.status(404).json({message: 'no configured SMS gateways'});
}
} catch (err) {
this.logger.error(err, 'TaskMessage:exec - Error sending SMS');
res.status(422).json({message: 'no configured SMS gateways'});
this.logger.error(err, 'TaskMessage:exec - unexpected error sending SMS');
this.performAction({
...actionParams,
message_status: 'system error',
message_failure_reason: err.message
});
if (res) res.status(422).json({message: 'no configured SMS gateways'});
}
}
}

View File

@@ -17,8 +17,12 @@ class TaskPlay extends Task {
await super.exec(cs);
this.ep = ep;
try {
while (!this.killed && this.loop--) {
await ep.play(this.url);
while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep.connected) {
if (cs.isInConference) {
const {memberId, confName, confUuid} = cs;
await this.playToConfMember(this.ep, memberId, confName, confUuid, this.url);
}
else await ep.play(this.url);
}
} catch (err) {
this.logger.info(err, `TaskPlay:exec - error playing ${this.url}`);
@@ -30,7 +34,13 @@ class TaskPlay extends Task {
super.kill(cs);
if (this.ep.connected && !this.playComplete) {
this.logger.debug('TaskPlay:kill - killing audio');
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
if (cs.isInConference) {
const {memberId, confName} = cs;
this.killPlayToConfMember(this.ep, memberId, confName);
}
else {
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
}
}
}
}

156
lib/tasks/rasa.js Normal file
View File

@@ -0,0 +1,156 @@
const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants');
const makeTask = require('./make_task');
const bent = require('bent');
class Rasa extends Task {
constructor(logger, opts) {
super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint;
this.prompt = this.data.prompt;
this.eventHook = this.data?.eventHook;
this.actionHook = this.data?.actionHook;
this.post = bent('POST', 'json', 200);
}
get name() { return TaskName.Rasa; }
get hasReportedFinalAction() {
return this.reportedFinalAction || this.isReplacingApplication;
}
async exec(cs, ep) {
await super.exec(cs);
this.ep = ep;
try {
/* set event handlers */
this.on('transcription', this._onTranscription.bind(this, cs, ep));
this.on('timeout', this._onTimeout.bind(this, cs, ep));
/* start the first gather */
this.gatherTask = this._makeGatherTask(this.prompt);
this.gatherTask.exec(cs, ep, this)
.catch((err) => this.logger.info({err}, 'Rasa gather task returned error'));
await this.awaitTaskDone();
} catch (err) {
this.logger.error({err}, 'Rasa error');
throw err;
}
}
async kill(cs) {
super.kill(cs);
this.logger.debug('Rasa:kill');
if (!this.hasReportedFinalAction) {
this.reportedFinalAction = true;
this.performAction({rasaResult: 'caller hungup'})
.catch((err) => this.logger.info({err}, 'rasa - error w/ action webook'));
}
if (this.ep.connected) {
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
}
this.removeAllListeners();
this.notifyTaskDone();
}
_makeGatherTask(prompt) {
let opts = {
input: ['speech'],
timeout: this.data.timeout || 10,
recognizer: this.data.recognizer || {
vendor: 'default',
language: 'default'
}
};
if (prompt) {
const sayOpts = this.data.tts ?
{text: prompt, synthesizer: this.data.tts} :
{text: prompt};
opts = {
...opts,
say: sayOpts
};
}
//this.logger.debug({opts}, 'constructing a nested gather object');
const gather = makeTask(this.logger, {gather: opts}, this);
return gather;
}
async _onTranscription(cs, ep, evt) {
//this.logger.debug({evt}, `Rasa: got transcription for callSid ${cs.callSid}`);
const utterance = evt.alternatives[0].transcript;
if (this.eventHook) {
this.performHook(cs, this.eventHook, {event: 'userMessage', message: utterance})
.then((redirected) => {
if (redirected) {
this.logger.info('Rasa_onTranscription: event handler for user message redirected us to new webhook');
this.reportedFinalAction = true;
this.performAction({rasaResult: 'redirect'}, false);
if (this.gatherTask) this.gatherTask.kill(cs);
}
return;
})
.catch(({err}) => {
this.logger.info({err}, 'Rasa_onTranscription: error sending event hook');
});
}
try {
const payload = {
sender: cs.callSid,
message: utterance
};
this.logger.debug({payload}, 'Rasa:_onTranscription - sending payload to Rasa');
const response = await this.post(this.data.url, payload);
this.logger.debug({response}, 'Rasa:_onTranscription - got response from Rasa');
const botUtterance = Array.isArray(response) ?
response.reduce((prev, current) => {
return current.text ? `${prev} ${current.text}` : '';
}, '') :
null;
if (botUtterance) {
this.logger.debug({botUtterance}, 'Rasa:_onTranscription: got user utterance');
this.gatherTask = this._makeGatherTask(botUtterance);
this.gatherTask.exec(cs, ep, this)
.catch((err) => this.logger.info({err}, 'Rasa gather task returned error'));
if (this.eventHook) {
this.performHook(cs, this.eventHook, {event: 'botMessage', message: response})
.then((redirected) => {
if (redirected) {
this.logger.info('Rasa_onTranscription: event handler for bot message redirected us to new webhook');
this.reportedFinalAction = true;
this.performAction({rasaResult: 'redirect'}, false);
if (this.gatherTask) this.gatherTask.kill(cs);
}
return;
})
.catch(({err}) => {
this.logger.info({err}, 'Rasa_onTranscription: error sending event hook');
});
}
}
} catch (err) {
this.logger.error({err}, 'Rasa_onTranscription: Error sending user utterance to Rasa - ending task');
this.performAction({rasaResult: 'webhookError'});
this.reportedFinalAction = true;
this.notifyTaskDone();
}
}
_onTimeout(cs, ep, evt) {
this.logger.debug({evt}, 'Rasa: got timeout');
if (!this.hasReportedFinalAction) this.performAction({rasaResult: 'timeout'});
this.reportedFinalAction = true;
this.notifyTaskDone();
}
}
module.exports = Rasa;

View File

@@ -19,14 +19,17 @@ class TaskSay extends Task {
const {srf} = cs;
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, srf);
const {writeAlerts, AlertType} = srf.locals;
const {writeAlerts, AlertType, stats} = srf.locals;
const {synthAudio} = srf.locals.dbHelpers;
const vendor = this.synthesizer.vendor || cs.speechSynthesisVendor;
const language = this.synthesizer.language || cs.speechSynthesisLanguage;
const voice = this.synthesizer.voice || cs.speechSynthesisVoice;
const hasVerbLevelTts = this.synthesizer.vendor && this.synthesizer.vendor !== 'default';
const vendor = hasVerbLevelTts ? this.synthesizer.vendor : cs.speechSynthesisVendor ;
const language = hasVerbLevelTts ? this.synthesizer.language : cs.speechSynthesisLanguage ;
const voice = hasVerbLevelTts ? this.synthesizer.voice : cs.speechSynthesisVoice ;
const engine = this.synthesizer.engine || 'standard';
const salt = cs.callSid;
const credentials = cs.getSpeechCredentials(vendor, 'tts');
this.logger.info({language, voice}, `Task:say - using vendor: ${vendor}`);
this.ep = ep;
try {
if (!credentials) {
@@ -40,11 +43,12 @@ class TaskSay extends Task {
// synthesize all of the text elements
let lastUpdated = false;
const filepath = (await Promise.all(this.text.map(async(text) => {
const {filePath, servedFromCache} = await synthAudio({
const {filePath, servedFromCache} = await synthAudio(stats, {
text,
vendor,
language,
voice,
engine,
salt,
credentials
}).catch((err) => {
@@ -68,10 +72,14 @@ class TaskSay extends Task {
this.logger.debug({filepath}, 'synthesized files for tts');
while (!this.killed && this.loop-- && this.ep.connected) {
while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep.connected) {
let segment = 0;
do {
await ep.play(filepath[segment]);
if (cs.isInConference) {
const {memberId, confName, confUuid} = cs;
await this.playToConfMember(this.ep, memberId, confName, confUuid, filepath[segment]);
}
else await ep.play(filepath[segment]);
} while (!this.killed && ++segment < filepath.length);
}
} catch (err) {
@@ -84,7 +92,13 @@ class TaskSay extends Task {
super.kill(cs);
if (this.ep.connected) {
this.logger.debug('TaskSay:kill - killing audio');
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
if (cs.isInConference) {
const {memberId, confName} = cs;
this.killPlayToConfMember(this.ep, memberId, confName);
}
else {
this.ep.api('uuid_break', this.ep.uuid);
}
}
}
}

101
lib/tasks/sip_refer.js Normal file
View File

@@ -0,0 +1,101 @@
const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants');
const {parseUri} = require('drachtio-srf');
/**
* sends a sip REFER to transfer the existing call
*/
class TaskSipRefer extends Task {
constructor(logger, opts) {
super(logger, opts);
this.preconditions = TaskPreconditions.StableCall;
this.referTo = this.data.referTo;
this.referredBy = this.data.referredBy;
this.headers = this.data.headers || {};
this.eventHook = this.data.eventHook;
}
get name() { return TaskName.SipRefer; }
async exec(cs) {
super.exec(cs);
const {dlg} = cs;
const {referTo, referredBy} = this._normalizeReferHeaders(cs, dlg);
try {
this.notifyHandler = this._handleNotify.bind(this, cs, dlg);
dlg.on('notify', this.notifyHandler);
const response = await dlg.request({
method: 'REFER',
headers: {
...this.headers,
'Refer-To': referTo,
'Referred-By': referredBy
}
});
this.referStatus = response.status;
this.logger.info(`TaskSipRefer:exec - received ${this.referStatus} to REFER`);
/* if we fail, fall through to next verb. If success, we should get BYE from far end */
if (this.referStatus === 202) {
await this.awaitTaskDone();
}
else await this.performAction({refer_status: this.referStatus});
} catch (err) {
this.logger.info({err}, 'TaskSipRefer:exec - error sending REFER');
}
}
async kill(cs) {
super.kill(cs);
const {dlg} = cs;
dlg.off('notify', this.notifyHandler);
}
async _handleNotify(cs, dlg, req, res) {
res.send(200);
const contentType = req.get('Content-Type');
this.logger.debug({body: req.body}, `TaskSipRefer:_handleNotify got ${contentType}`);
if (contentType === 'message/sipfrag') {
const arr = /SIP\/2\.0\s+(\d+)/.exec(req.body);
if (arr) {
const status = arr[1];
this.logger.debug(`TaskSipRefer:_handleNotify: call got status ${status}`);
if (this.eventHook) {
await cs.requestor.request(this.eventHook, {event: 'transfer-status', call_status: status});
}
if (status >= 200) {
await this.performAction({refer_status: 202, final_referred_call_status: status});
this.notifyTaskDone();
}
}
}
}
_normalizeReferHeaders(cs, dlg) {
let {referTo, referredBy} = this;
/* get IP address of the SBC to use as hostname if needed */
const {host} = parseUri(dlg.remote.uri);
if (!referTo.startsWith('<') && !referTo.startsWith('sip') && !referTo.startsWith('"')) {
/* they may have only provided a phone number/user */
referTo = `sip:${referTo}@${host}`;
}
if (!referredBy) {
/* default */
referredBy = cs.req?.callingNumber || dlg.local.uri;
this.logger.info({referredBy}, 'setting referredby');
}
if (!referredBy.startsWith('<') && !referredBy.startsWith('sip') && !referredBy.startsWith('"')) {
/* they may have only provided a phone number/user */
referredBy = `sip:${referredBy}@${host}`;
}
return {referTo, referredBy};
}
}
module.exports = TaskSipRefer;

View File

@@ -9,6 +9,34 @@
"status"
]
},
"sip:refer": {
"properties": {
"referTo": "string",
"referredBy": "string",
"headers": "object",
"actionHook": "object|string",
"eventHook": "object|string"
},
"required": [
"referTo"
]
},
"cognigy": {
"properties": {
"url": "string",
"token": "string",
"recognizer": "#recognizer",
"tts": "#synthesizer",
"prompt": "string",
"actionHook": "object|string",
"eventHook": "object|string",
"data": "object"
},
"required": [
"url",
"token"
]
},
"dequeue": {
"properties": {
"name": "string",
@@ -46,7 +74,7 @@
"play": {
"properties": {
"url": "string",
"loop": "number",
"loop": "number|string",
"earlyMedia": "boolean"
},
"required": [
@@ -56,7 +84,7 @@
"say": {
"properties": {
"text": "string|array",
"loop": "number",
"loop": "number|string",
"synthesizer": "#synthesizer",
"earlyMedia": "boolean"
},
@@ -78,7 +106,6 @@
"say": "#say"
},
"required": [
"actionHook"
]
},
"conference": {
@@ -88,11 +115,13 @@
"startConferenceOnEnter": "boolean",
"endConferenceOnExit": "boolean",
"maxParticipants": "number",
"joinMuted": "boolean",
"actionHook": "object|string",
"waitHook": "object|string",
"statusEvents": "array",
"statusHook": "object|string",
"enterHook": "object|string"
"enterHook": "object|string",
"record": "#record"
},
"required": [
"name"
@@ -124,6 +153,10 @@
"credentials": "object|string",
"project": "string",
"environment": "string",
"region": {
"type": "string",
"enum": ["europe-west1", "europe-west2", "australia-southeast1", "asia-northeast1"]
},
"lang": "string",
"actionHook": "object|string",
"eventHook": "object|string",
@@ -224,6 +257,27 @@
"length"
]
},
"rasa": {
"properties": {
"url": "string",
"recognizer": "#recognizer",
"tts": "#synthesizer",
"prompt": "string",
"actionHook": "object|string",
"eventHook": "object|string"
},
"required": [
"url"
]
},
"record": {
"properties": {
"path": "string"
},
"required": [
"path"
]
},
"redirect": {
"properties": {
"actionHook": "object|string"
@@ -270,7 +324,6 @@
"earlyMedia": "boolean"
},
"required": [
"transcriptionHook",
"recognizer"
]
},
@@ -285,12 +338,15 @@
"type": "string",
"enum": ["GET", "POST"]
},
"headers": "object",
"name": "string",
"number": "string",
"sipUri": "string",
"auth": "#auth",
"vmail": "boolean",
"tenant": "string"
"tenant": "string",
"trunk": "string",
"overrideTo": "string"
},
"required": [
"type"
@@ -310,10 +366,14 @@
"properties": {
"vendor": {
"type": "string",
"enum": ["google", "aws", "polly", "default"]
"enum": ["google", "aws", "polly", "microsoft", "default"]
},
"language": "string",
"voice": "string",
"engine": {
"type": "string",
"enum": ["standard", "neural"]
},
"gender": {
"type": "string",
"enum": ["MALE", "FEMALE", "NEUTRAL"]
@@ -327,9 +387,10 @@
"properties": {
"vendor": {
"type": "string",
"enum": ["google", "aws", "default"]
"enum": ["google", "aws", "microsoft", "default"]
},
"language": "string",
"vad": "#vad",
"hints": "array",
"altLanguages": "array",
"profanityFilter": "boolean",
@@ -367,7 +428,24 @@
"mask",
"tag"
]
}
},
"outputFormat": {
"type": "string",
"enum": [
"simple",
"detailed"
]
},
"profanityOption": {
"type": "string",
"enum": [
"masked",
"removed",
"raw"
]
},
"requestSnr": "boolean",
"initialSpeechTimeoutMs": "number"
},
"required": [
"vendor"
@@ -381,5 +459,15 @@
"required": [
"name"
]
},
"vad": {
"properties": {
"enable": "boolean",
"voiceMs": "number",
"mode": "number"
},
"required": [
"enable"
]
}
}

View File

@@ -23,6 +23,9 @@ class Task extends Emitter {
this._killInProgress = false;
this._completionPromise = new Promise((resolve) => this._completionResolver = resolve);
/* used when we play a prompt to a member in conference */
this._confPlayCompletionPromise = new Promise((resolve) => this._confPlayCompletionResolver = resolve);
}
/**
@@ -59,7 +62,9 @@ class Task extends Emitter {
kill(cs) {
if (this.cs && !this.cs.isConfirmCallSession) this.logger.debug(`${this.name} is being killed`);
this._killInProgress = true;
// no-op
/* remove reference to parent task or else entangled parent-child tasks will not be gc'ed */
setImmediate(() => this.parentTask = null);
}
/**
@@ -77,6 +82,21 @@ class Task extends Emitter {
return this._completionPromise;
}
/**
* when a play to conference member completes
*/
notifyConfPlayDone() {
this._confPlayCompletionResolver();
}
/**
* when a subclass task has launched various async activities and is now simply waiting
* for them to complete it should call this method to block until that happens
*/
awaitConfPlayDone() {
return this._confPlayCompletionPromise;
}
/**
* provided as a convenience for tasks, this simply calls CallSession#normalizeUrl
*/
@@ -99,6 +119,62 @@ class Task extends Emitter {
}
}
async performHook(cs, hook, results) {
const json = await 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.redirect(cs, tasks);
return true;
}
}
return false;
}
redirect(cs, tasks) {
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
this.isReplacingApplication = true;
cs.replaceApplication(tasks);
}
async playToConfMember(ep, memberId, confName, confUuid, filepath) {
try {
this.logger.debug(`Task:playToConfMember - playing ${filepath} to ${confName}:${memberId}`);
// listen for conference events
const handler = this.__onConferenceEvent.bind(this);
ep.conn.on('esl::event::CUSTOM::*', handler) ;
const response = await ep.api(`conference ${confName} play ${filepath} ${memberId}`);
this.logger.debug({response}, 'Task:playToConfMember - api call returned');
await this.awaitConfPlayDone();
ep.conn.removeListener('esl::event::CUSTOM::*', handler);
} catch (err) {
this.logger.error({err}, `Task:playToConfMember - error playing ${filepath} to ${confName}:${memberId}`);
}
}
async killPlayToConfMember(ep, memberId, confName) {
try {
this.logger.debug(`Task:killPlayToConfMember - killing audio to ${confName}:${memberId}`);
const response = await ep.api(`conference ${confName} stop ${memberId}`);
this.logger.debug({response}, 'Task:killPlayToConfMember - api call returned');
} catch (err) {
this.logger.error({err}, `Task:killPlayToConfMember - error killing audio to ${confName}:${memberId}`);
}
}
__onConferenceEvent(evt) {
const eventName = evt.getHeader('Event-Subclass') ;
if (eventName === 'conference::maintenance') {
const action = evt.getHeader('Action') ;
if (action === 'play-file-member-done') {
this.logger.debug('done playing file to conf member');
this.notifyConfPlayDone();
}
}
}
async transferCallToFeatureServer(cs, sipAddress, opts) {
const uuid = uuidv4();
const {addKey} = cs.srf.locals.dbHelpers;

View File

@@ -3,6 +3,7 @@ const {
TaskName,
TaskPreconditions,
GoogleTranscriptionEvents,
AzureTranscriptionEvents,
AwsTranscriptionEvents
} = require('../utils/constants');
@@ -10,6 +11,7 @@ class TaskTranscribe extends Task {
constructor(logger, opts, parentTask) {
super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint;
this.parentTask = parentTask;
this.transcriptionHook = this.data.transcriptionHook;
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
@@ -20,6 +22,10 @@ class TaskTranscribe extends Task {
this.interim = !!recognizer.interim;
this.separateRecognitionPerChannel = recognizer.separateRecognitionPerChannel;
/* vad: if provided, we dont connect to recognizer until voice activity is detected */
const {enable, voiceMs = 0, mode = -1} = recognizer.vad || {};
this.vad = {enable, voiceMs, mode};
/* google-specific options */
this.hints = recognizer.hints || [];
this.profanityFilter = recognizer.profanityFilter;
@@ -38,6 +44,12 @@ class TaskTranscribe extends Task {
this.vocabularyName = recognizer.vocabularyName;
this.vocabularyFilterName = recognizer.vocabularyFilterName;
this.filterMethod = recognizer.filterMethod;
/* microsoft options */
this.outputFormat = recognizer.outputFormat || 'simple';
this.profanityOption = recognizer.profanityOption || 'raw';
this.requestSnr = recognizer.requestSnr || false;
this.initialSpeechTimeoutMs = recognizer.initialSpeechTimeoutMs || 0;
}
get name() { return TaskName.Transcribe; }
@@ -53,7 +65,13 @@ class TaskTranscribe extends Task {
try {
if (!this.sttCredentials) {
// TODO: generate alert (actually should be done by cs.getSpeechCredentials)
const {writeAlerts, AlertType} = cs.srf.locals;
this.logger.info(`TaskTranscribe:exec - ERROR stt using ${this.vendor} requested but creds not supplied`);
writeAlerts({
account_sid: cs.accountSid,
alert_type: AlertType.STT_NOT_PROVISIONED,
vendor: this.vendor
}).catch((err) => this.logger.info({err}, 'Error generating alert for no stt'));
throw new Error('no provisioned speech credentials for TTS');
}
await this._startTranscribing(cs, ep);
@@ -63,6 +81,7 @@ class TaskTranscribe extends Task {
await this.awaitTaskDone();
} catch (err) {
this.logger.info(err, 'TaskTranscribe:exec - error');
this.parentTask && this.parentTask.emit('error', err);
}
ep.removeCustomEventListener(GoogleTranscriptionEvents.Transcription);
ep.removeCustomEventListener(GoogleTranscriptionEvents.NoAudioDetected);
@@ -70,6 +89,8 @@ class TaskTranscribe extends Task {
ep.removeCustomEventListener(AwsTranscriptionEvents.Transcription);
ep.removeCustomEventListener(AwsTranscriptionEvents.NoAudioDetected);
ep.removeCustomEventListener(AwsTranscriptionEvents.MaxDurationExceeded);
ep.removeCustomEventListener(AzureTranscriptionEvents.Transcription);
ep.removeCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected);
}
async kill(cs) {
@@ -88,14 +109,22 @@ class TaskTranscribe extends Task {
async _startTranscribing(cs, ep) {
const opts = {};
if (this.vad.enable) {
opts.START_RECOGNIZING_ON_VAD = 1;
if (this.vad.voiceMs) opts.RECOGNIZER_VAD_VOICE_MS = this.vad.voiceMs;
if (this.vad.mode >= 0 && this.vad.mode <= 3) opts.RECOGNIZER_VAD_MODE = this.vad.mode;
}
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(GoogleTranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, cs, ep));
ep.addCustomEventListener(GoogleTranscriptionEvents.MaxDurationExceeded,
this._onMaxDurationExceeded.bind(this, ep));
this._onMaxDurationExceeded.bind(this, cs, ep));
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(AwsTranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, cs, ep));
ep.addCustomEventListener(AwsTranscriptionEvents.MaxDurationExceeded,
this._onMaxDurationExceeded.bind(this, cs, ep));
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected, this._onNoAudio.bind(this, cs, ep));
if (this.vendor === 'google') {
if (this.sttCredentials) opts.GOOGLE_APPLICATION_CREDENTIALS = JSON.stringify(this.sttCredentials.credentials);
@@ -164,6 +193,22 @@ class TaskTranscribe extends Task {
await ep.set(opts)
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with aws'));
}
else if (this.vendor === 'microsoft') {
Object.assign(opts, {
'AZURE_SUBSCRIPTION_KEY': this.sttCredentials.api_key,
'AZURE_REGION': this.sttCredentials.region
});
if (this.hints && this.hints.length > 1) {
opts.AZURE_SPEECH_HINTS = this.hints.map((h) => h.trim()).join(',');
}
if (this.requestSnr) opts.AZURE_REQUEST_SNR = 1;
if (this.profanityOption !== 'raw') opts.AZURE_PROFANITY_OPTION = this.profanityOption;
if (this.initialSpeechTimeoutMs > 0) opts.AZURE_INITIAL_SPEECH_TIMEOUT_MS = this.initialSpeechTimeoutMs;
if (this.outputFormat !== 'simple') opts.AZURE_USE_OUTPUT_FORMAT_DETAILED = 1;
await ep.set(opts)
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with azure'));
}
await this._transcribe(ep);
}
@@ -177,11 +222,36 @@ class TaskTranscribe extends Task {
}
_onTranscription(cs, ep, evt) {
if ('aws' === this.vendor && Array.isArray(evt) && evt.length > 0) evt = evt[0];
this.logger.debug(evt, 'TaskTranscribe:_onTranscription');
if ('aws' === this.vendor && Array.isArray(evt) && evt.length > 0) evt = evt[0];
if ('microsoft' === this.vendor) {
const nbest = evt.NBest;
const alternatives = nbest ? nbest.map((n) => {
return {
confidence: n.Confidence,
transcript: n.Display
};
}) :
[
{
transcript: evt.DisplayText
}
];
this.cs.requestor.request(this.transcriptionHook, Object.assign({speech: evt}, this.cs.callInfo))
.catch((err) => this.logger.info(err, 'TranscribeTask:_onTranscription error'));
const newEvent = {
is_final: evt.RecognitionStatus === 'Success',
alternatives
};
evt = newEvent;
}
if (this.transcriptionHook) {
this.cs.requestor.request(this.transcriptionHook, Object.assign({speech: evt}, this.cs.callInfo))
.catch((err) => this.logger.info(err, 'TranscribeTask:_onTranscription error'));
}
if (this.parentTask) {
this.parentTask.emit('transcription', evt);
}
if (this.killed) {
this.logger.debug('TaskTranscribe:_onTranscription exiting after receiving final transcription');
this._clearTimer();

View File

@@ -45,6 +45,7 @@ class SnsNotifier extends Emitter {
}, 'response from SNS SubscribeURL');
const data = await this.describeInstance();
this.lifecycleState = data.AutoScalingInstances[0].LifecycleState;
this.emit('SubscriptionConfirmation', {publicIp: this.publicIp});
break;
case 'Notification':

View File

@@ -1,5 +1,6 @@
{
"TaskName": {
"Cognigy": "cognigy",
"Conference": "conference",
"Dequeue": "dequeue",
"Dial": "dial",
@@ -14,9 +15,11 @@
"Message": "message",
"Pause": "pause",
"Play": "play",
"Rasa": "rasa",
"Redirect": "redirect",
"RestDial": "rest:dial",
"SipDecline": "sip:decline",
"SipRefer": "sip:refer",
"SipNotify": "sip:notify",
"SipRedirect": "sip:redirect",
"Say": "say",
@@ -63,6 +66,12 @@
"NoAudioDetected": "aws_transcribe::no_audio_detected",
"MaxDurationExceeded": "aws_transcribe::max_duration_exceeded"
},
"AzureTranscriptionEvents": {
"Transcription": "azure_transcribe::transcription",
"StartOfUtterance": "azure_transcribe::start_of_utterance",
"EndOfUtterance": "azure_transcribe::end_of_utterance",
"NoSpeechDetected": "azure_transcribe::no_speech_detected"
},
"ListenEvents": {
"Connect": "mod_audio_fork::connect",
"ConnectFailure": "mod_audio_fork::connect_failed",
@@ -97,5 +106,6 @@
"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)"
"BONG_TONE": "tone_stream://v=-7;%(100,0,941.0,1477.0);v=-7;>=2;+=.1;%(1400,0,350,440)",
"FS_UUID_SET_NAME": "fsUUIDs"
}

View File

@@ -10,6 +10,16 @@ const sqlSpeechCredentialsForSP = `SELECT *
FROM speech_credentials
WHERE service_provider_sid =
(SELECT service_provider_sid from accounts where account_sid = ?)`;
const sqlQueryAccountCarrierByName = `SELECT voip_carrier_sid
FROM voip_carriers vc
WHERE vc.account_sid = ?
AND vc.name = ?`;
const sqlQuerySPCarrierByName = `SELECT voip_carrier_sid
FROM voip_carriers vc
WHERE vc.account_sid IS NULL
AND vc.service_provider_sid =
(SELECT service_provider_sid from accounts where account_sid = ?)
AND vc.name = ?`;
const speechMapper = (cred) => {
const {credential, ...obj} = cred;
@@ -21,6 +31,15 @@ const speechMapper = (cred) => {
obj.access_key_id = o.access_key_id;
obj.secret_access_key = o.secret_access_key;
}
else if ('microsoft' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
obj.region = o.region;
}
else if ('wellsaid' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
}
return obj;
};
@@ -37,7 +56,9 @@ module.exports = (logger, srf) => {
/* search at the service provider level if we don't find it at the account level */
const haveGoogle = speech.find((s) => s.vendor === 'google');
const haveAws = speech.find((s) => s.vendor === 'aws');
if (!haveGoogle || !haveAws) {
const haveMicrosoft = speech.find((s) => s.vendor === 'microsoft');
const haveWellsaid = speech.find((s) => s.vendor === 'wellsaid');
if (!haveGoogle || !haveAws || !haveMicrosoft) {
const [r3] = await pp.query(sqlSpeechCredentialsForSP, account_sid);
if (r3.length) {
if (!haveGoogle) {
@@ -48,6 +69,14 @@ module.exports = (logger, srf) => {
const aws = r3.find((s) => s.vendor === 'aws');
if (aws) speech.push(speechMapper(aws));
}
if (!haveMicrosoft) {
const ms = r3.find((s) => s.vendor === 'microsoft');
if (ms) speech.push(speechMapper(ms));
}
if (!haveWellsaid) {
const wellsaid = r3.find((s) => s.vendor === 'wellsaid');
if (wellsaid) speech.push(speechMapper(wellsaid));
}
}
}
@@ -67,8 +96,21 @@ module.exports = (logger, srf) => {
}
};
const lookupCarrier = async(account_sid, carrierName) => {
const pp = pool.promise();
try {
const [r] = await pp.query(sqlQueryAccountCarrierByName, [account_sid, carrierName]);
if (r.length) return r[0].voip_carrier_sid;
const [r2] = await pp.query(sqlQuerySPCarrierByName, [account_sid, carrierName]);
if (r2.length) return r2[0].voip_carrier_sid;
} catch (err) {
logger.error({err}, `lookupCarrier: Error ${account_sid}:${carrierName}`);
}
};
return {
lookupAccountDetails,
updateSpeechCredentialLastUsed
updateSpeechCredentialLastUsed,
lookupCarrier
};
};

View File

@@ -1,5 +1,5 @@
const crypto = require('crypto');
const algorithm = 'aes-256-ctr';
const algorithm = process.env.LEGACY_CRYPTO ? 'aes-256-ctr' : 'aes-256-cbc';
const iv = crypto.randomBytes(16);
const secretKey = crypto.createHash('sha256')
.update(String(process.env.JWT_SECRET))

View File

@@ -20,6 +20,13 @@ function initMS(logger, wrapper, ms) {
wrapper.connects = 1;
wrapper.active = true;
});
ms.on('channel::open', (evt) => {
logger.debug({evt}, `mediaserver ${ms.address} added endpoint`);
});
ms.on('channel::close', (evt) => {
logger.debug({evt}, `mediaserver ${ms.address} removed endpoint`);
});
}
function installSrfLocals(srf, logger) {
@@ -105,6 +112,7 @@ function installSrfLocals(srf, logger) {
const {
pool,
lookupAppByPhoneNumber,
lookupAppByRegex,
lookupAppBySid,
lookupAppByRealm,
lookupAppByTeamsTenant,
@@ -135,6 +143,7 @@ function installSrfLocals(srf, logger) {
retrieveSet,
addToSet,
removeFromSet,
monitorSet,
pushBack,
popFront,
removeFromList,
@@ -158,6 +167,7 @@ function installSrfLocals(srf, logger) {
client,
pool,
lookupAppByPhoneNumber,
lookupAppByRegex,
lookupAppBySid,
lookupAppByRealm,
lookupAppByTeamsTenant,
@@ -178,6 +188,7 @@ function installSrfLocals(srf, logger) {
retrieveSet,
addToSet,
removeFromSet,
monitorSet,
pushBack,
popFront,
removeFromList,

View File

@@ -5,19 +5,14 @@ const {TaskPreconditions, CallDirection} = require('../utils/constants');
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 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 stripCodecs = require('./strip-ancillary-codecs');
const { v4: uuidv4 } = require('uuid');
class SingleDialer extends Emitter {
constructor({logger, sbcAddress, target, opts, application, callInfo}) {
constructor({logger, sbcAddress, target, opts, application, callInfo, accountInfo}) {
super();
assert(target.type);
@@ -31,6 +26,8 @@ class SingleDialer extends Emitter {
this.bindings = logger.bindings();
this.parentCallInfo = callInfo;
this.accountInfo = accountInfo;
this.callGone = false;
this.callSid = uuidv4();
@@ -61,7 +58,19 @@ class SingleDialer extends Emitter {
async exec(srf, ms, opts) {
opts = opts || {};
opts.headers = opts.headers || {};
opts.headers = {...opts.headers, 'X-Call-Sid': this.callSid};
opts.headers = {
...opts.headers,
...(this.target.headers || {}),
'X-Jambonz-Routing': this.target.type,
'X-Call-Sid': this.callSid
};
if (srf.locals.fsUUID) {
opts.headers = {
...opts.headers,
'X-Jambonz-FS-UUID': srf.locals.fsUUID,
};
}
this.ms = ms;
let uri, to;
try {
switch (this.target.type) {
@@ -81,18 +90,13 @@ class SingleDialer extends Emitter {
break;
case 'user':
assert(this.target.name);
const aor = this.target.name;
uri = `sip:${this.target.name}`;
to = this.target.name;
// need to send to the SBC registered on
const reg = await registrar.query(aor);
if (reg) {
const sbc = selectSbc(reg.sbcAddress);
if (sbc) {
this.logger.debug(`SingleDialer:exec retrieved registration details for ${aor}, using sbc at ${sbc}`);
this.sbcAddress = sbc;
}
if (this.target.overrideTo) {
Object.assign(opts.headers, {
'X-Override-To': this.target.overrideTo
});
}
break;
case 'sip':
@@ -146,6 +150,7 @@ class SingleDialer extends Emitter {
* (a) create a CallInfo for this call
* (a) create a logger for this call
*/
req.srf = srf;
this.callInfo = new CallInfo({
direction: CallDirection.Outbound,
parentCallInfo: this.parentCallInfo,
@@ -181,19 +186,34 @@ 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');
this.logger.debug('SingleDialer:exec called party hung up');
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
this.ep.destroy();
this.ep && this.ep.destroy();
})
.on('refresh', () => this.logger.info('SingleDialer:exec - dialog refreshed by uas'))
.on('modify', async(req, res) => {
try {
const newSdp = await this.ep.modify(req.body);
res.send(200, {body: newSdp});
this.logger.info({offer: req.body, answer: newSdp}, 'SingleDialer:exec: handling reINVITE');
if (this.ep) {
const newSdp = await this.ep.modify(req.body);
res.send(200, {body: newSdp});
this.logger.info({offer: req.body, answer: newSdp}, 'SingleDialer:exec: handling reINVITE');
}
else {
this.logger.info('SingleDialer:exec: handling reINVITE with released media, emit event');
this.emit('reinvite', req, res);
}
} catch (err) {
this.logger.error(err, 'Error handling reinvite');
}
@@ -202,6 +222,7 @@ class SingleDialer extends Emitter {
if (this.confirmHook) this._executeApp(this.confirmHook);
else this.emit('accept');
} catch (err) {
this.inviteInProgress = null;
const status = {callStatus: CallStatus.Failed};
if (err instanceof SipError) {
status.sipStatus = err.status;
@@ -246,7 +267,7 @@ class SingleDialer extends Emitter {
async _executeApp(confirmHook) {
try {
// retrieve set of tasks
const tasks = await this.requestor.request(confirmHook, this.callInfo);
const tasks = await this.requestor.request(confirmHook, this.callInfo.toJSON());
// verify it contains only allowed verbs
const allowedTasks = tasks.filter((task) => {
@@ -284,20 +305,49 @@ class SingleDialer extends Emitter {
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');
if (this.ep) {
await this.ep.unbridge()
.catch((err) => this.logger.info({err}, 'SingleDialer:doAdulting - failed to unbridge ep'));
this.ep.play('silence_stream://1000');
}
else {
await this.reAnchorMedia();
}
const cs = new AdultingCallSession({
logger: this.logger,
singleDialer: this,
application,
callInfo: this.callInfo,
accountInfo: this.accountInfo,
tasks
});
cs.exec();
return cs;
}
async releaseMediaToSBC(remoteSdp, localSdp) {
assert(this.dlg && this.dlg.connected && this.ep && typeof remoteSdp === 'string');
const sdp = stripCodecs(this.logger, remoteSdp, localSdp) || remoteSdp;
await this.dlg.modify(sdp, {
headers: {
'X-Reason': 'release-media'
}
});
this.ep.destroy()
.then(() => this.ep = null)
.catch((err) => this.logger.error({err}, 'SingleDialer:releaseMediaToSBC: Error destroying endpoint'));
}
async reAnchorMedia() {
assert(this.dlg && this.dlg.connected && !this.ep);
this.ep = await this.ms.createEndpoint({remoteSdp: this.dlg.remote.sdp});
await this.dlg.modify(this.ep.local.sdp, {
headers: {
'X-Reason': 'anchor-media'
}
});
}
_notifyCallStatusChange({callStatus, sipStatus, duration}) {
assert((typeof duration === 'number' && callStatus === CallStatus.Completed) ||
(!duration && callStatus !== CallStatus.Completed),
@@ -320,9 +370,9 @@ class SingleDialer extends Emitter {
}
}
function placeOutdial({logger, srf, ms, sbcAddress, target, opts, application, callInfo}) {
function placeOutdial({logger, srf, ms, sbcAddress, target, opts, application, callInfo, accountInfo}) {
const myOpts = deepcopy(opts);
const sd = new SingleDialer({logger, sbcAddress, target, myOpts, application, callInfo});
const sd = new SingleDialer({logger, sbcAddress, target, myOpts, application, callInfo, accountInfo});
sd.exec(srf, ms, myOpts);
return sd;
}

View File

@@ -100,15 +100,15 @@ 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, payload}, `Requestor:request ${method} ${url}`);
const {url: urlInfo = hook, method: methodInfo = 'POST'} = hook; // mask user/pass
this.logger.debug({url: urlInfo, method: methodInfo, payload}, `Requestor:request ${method} ${url}`);
const startAt = process.hrtime();
let buf;
try {
const sigHeader = generateSigHeader(payload, this.secret);
const headers = {...sigHeader, ...this.authHeader};
this.logger.info({url, headers}, 'send webhook');
//this.logger.info({url, headers}, 'send webhook');
buf = isRelativeUrl(url) ?
await this.post(url, payload, headers) :
await bent(method, 'buffer', 200, 201, 202)(url, payload, headers);

View File

@@ -1,19 +1,22 @@
const assert = require('assert');
const noopLogger = {info: () => {}, error: () => {}};
const {LifeCycleEvents} = require('./constants');
const { v4: uuidv4 } = require('uuid');
const {LifeCycleEvents, FS_UUID_SET_NAME} = require('./constants');
const Emitter = require('events');
const debug = require('debug')('jambonz:feature-server');
const noopLogger = {info: () => {}, error: () => {}};
module.exports = (logger) => {
logger = logger || noopLogger;
let idxSbc = 0;
let sbcs = [];
assert.ok(process.env.JAMBONES_SBCS, 'missing JAMBONES_SBCS env var');
const sbcs = process.env.JAMBONES_SBCS
.split(',')
.map((sbc) => sbc.trim());
assert.ok(sbcs.length, 'JAMBONES_SBCS env var is empty or misconfigured');
logger.info({sbcs}, 'SBC inventory');
if (process.env.JAMBONES_SBCS) {
sbcs = process.env.JAMBONES_SBCS
.split(',')
.map((sbc) => sbc.trim());
assert.ok(sbcs.length, 'JAMBONES_SBCS env var is empty or misconfigured');
logger.info({sbcs}, 'SBC inventory');
}
// listen for SNS lifecycle changes
let lifecycleEmitter = new Emitter();
@@ -25,6 +28,10 @@ module.exports = (logger) => {
lifecycleEmitter = await require('./aws-sns-lifecycle')(logger);
lifecycleEmitter
.on('SubscriptionConfirmation', ({publicIp}) => {
const {srf} = require('../..');
srf.locals.publicIp = publicIp;
})
.on(LifeCycleEvents.ScaleIn, () => {
logger.info('AWS scale-in notification: begin drying up calls');
dryUpCalls = true;
@@ -65,7 +72,6 @@ module.exports = (logger) => {
})();
}
// send OPTIONS pings to SBCs
async function pingProxies(srf) {
if (process.env.NODE_ENV === 'test') return;
@@ -88,18 +94,40 @@ module.exports = (logger) => {
}
}
}
if (process.env.K8S) {
setImmediate(() => {
logger.info('disabling OPTIONS pings since we are running as a kubernetes service');
const {srf} = require('../..');
const {addToSet} = srf.locals.dbHelpers;
const uuid = srf.locals.fsUUID = uuidv4();
addToSet(FS_UUID_SET_NAME, uuid)
.catch((err) => logger.info({err}, `Error adding ${uuid} to set ${FS_UUID_SET_NAME}`));
});
}
else {
// OPTIONS ping the SBCs from each feature server every 60 seconds
setInterval(() => {
const {srf} = require('../..');
pingProxies(srf);
}, process.env.OPTIONS_PING_INTERVAL || 30000);
// OPTIONS ping the SBCs from each feature server every 60 seconds
setInterval(() => {
const {srf} = require('../..');
pingProxies(srf);
}, 20000);
// initial ping once we are up
setTimeout(async() => {
// initial ping once we are up
setTimeout(() => {
const {srf} = require('../..');
pingProxies(srf);
}, 1000);
// if SBCs are auto-scaling, monitor them as they come and go
const {srf} = require('../..');
if (!process.env.JAMBONES_SBCS) {
const {monitorSet} = srf.locals.dbHelpers;
const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-sip`;
await monitorSet(setName, 10, (members) => {
sbcs = members;
logger.info(`sbc-pinger: SBC roster has changed, list of active SBCs is now ${sbcs}`);
});
}
pingProxies(srf);
}, 1000);
}
return {
lifecycleEmitter,

View File

@@ -1,13 +0,0 @@
const CIDRMatcher = require('cidr-matcher');
const matcher = new CIDRMatcher([process.env.JAMBONES_NETWORK_CIDR]);
module.exports = (sbcList) => {
const obj = sbcList
.split(',')
.map((str) => {
const arr = /^(.*)\/(.*):(\d+)$/.exec(str);
return {protocol: arr[1], host: arr[2], port: arr[3]};
})
.find((obj) => 'udp' == obj.protocol && matcher.contains(obj.host));
if (obj) return `${obj.host}:${obj.port}`;
};

View File

@@ -0,0 +1,30 @@
const sdpTransform = require('sdp-transform');
const stripCodecs = (logger, remoteSdp, localSdp) => {
try {
const sdp = sdpTransform.parse(remoteSdp);
const local = sdpTransform.parse(localSdp);
const m = local.media
.find((m) => 'audio' === m.type);
const pt = m.rtp[0].payload;
/* manipulate on the audio section */
const audio = sdp.media.find((m) => 'audio' === m.type);
/* discard all of the codecs except the first in our 200 OK, and telephony-events */
const ptSaves = audio.rtp
.filter((r) => r.codec === 'telephone-event' || r.payload === pt)
.map((r) => r.payload);
const rtp = audio.rtp.filter((r) => ptSaves.includes(r.payload));
/* reattach the new rtp sections and stripped payload list */
audio.rtp = rtp;
audio.payloads = rtp.map((r) => r.payload).join(' ');
return sdpTransform.write(sdp);
} catch (err) {
logger.error({err, remoteSdp, localSdp}, 'strip-ancillary-codecs error');
}
};
module.exports = stripCodecs;

3157
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.3.1",
"version": "v0.7.3",
"main": "app.js",
"engines": {
"node": ">= 10.16.0"
@@ -26,23 +26,26 @@
"jslint": "eslint app.js lib"
},
"dependencies": {
"@jambonz/db-helpers": "^0.6.13",
"@cognigy/socket-client": "^4.5.5",
"@jambonz/db-helpers": "^0.6.16",
"@jambonz/http-health-check": "^0.0.1",
"@jambonz/mw-registrar": "^0.2.1",
"@jambonz/realtimedb-helpers": "^0.4.1",
"@jambonz/stats-collector": "^0.1.5",
"@jambonz/time-series": "^0.1.5",
"aws-sdk": "^2.846.0",
"@jambonz/realtimedb-helpers": "^0.4.26",
"@jambonz/stats-collector": "^0.1.6",
"@jambonz/time-series": "^0.1.6",
"aws-sdk": "^2.1073.0",
"bent": "^7.3.12",
"cidr-matcher": "^2.1.1",
"debug": "^4.3.1",
"debug": "^4.3.2",
"deepcopy": "^2.1.0",
"drachtio-fsmrf": "^2.0.7",
"drachtio-srf": "^4.4.50",
"drachtio-fsmrf": "^2.0.13",
"drachtio-srf": "^4.4.61",
"express": "^4.17.1",
"helmet": "^5.0.2",
"ip": "^1.1.5",
"moment": "^2.29.1",
"parse-url": "^5.0.2",
"pino": "^6.11.2",
"parse-url": "^5.0.7",
"pino": "^6.13.4",
"to-snake-case": "^1.0.0",
"uuid": "^8.3.2",
"verify-aws-sns-signature": "^0.0.6",

View File

@@ -587,7 +587,8 @@ DROP TABLE IF EXISTS `speech_credentials`;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `speech_credentials` (
`speech_credential_sid` char(36) NOT NULL,
`account_sid` char(36) NOT NULL,
`service_provider_sid` CHAR(36),
`account_sid` char(36) NOT NULL,
`vendor` varchar(255) NOT NULL,
`credential` VARCHAR(8192) NOT NULL,
`use_for_tts` tinyint(1) DEFAULT '1',
@@ -611,7 +612,7 @@ CREATE TABLE `speech_credentials` (
LOCK TABLES `speech_credentials` WRITE;
/*!40000 ALTER TABLE `speech_credentials` DISABLE KEYS */;
INSERT INTO `speech_credentials` VALUES ('2add163c-34f2-45c6-a016-f955d218ffb6','bb845d4b-83a9-4cde-a6e9-50f3743bab3f','google','credential-goes-here',1,1,NULL,'2021-04-03 15:42:10',1,1),('84154212-5c99-4c94-8993-bc2a46288daa','bb845d4b-83a9-4cde-a6e9-50f3743bab3f','aws','credential-goes-here',0,0,NULL,NULL,NULL,NULL);
INSERT INTO `speech_credentials` VALUES ('2add163c-34f2-45c6-a016-f955d218ffb6',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','google','credential-goes-here',1,1,NULL,'2021-04-03 15:42:10',1,1),('84154212-5c99-4c94-8993-bc2a46288daa',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','aws','credential-goes-here',0,0,NULL,NULL,NULL,NULL);
/*!40000 ALTER TABLE `speech_credentials` ENABLE KEYS */;
UNLOCK TABLES;

View File

@@ -20,12 +20,16 @@ DROP TABLE IF EXISTS lcr_routes;
DROP TABLE IF EXISTS predefined_sip_gateways;
DROP TABLE IF EXISTS predefined_smpp_gateways;
DROP TABLE IF EXISTS predefined_carriers;
DROP TABLE IF EXISTS account_offers;
DROP TABLE IF EXISTS products;
DROP TABLE IF EXISTS schema_version;
DROP TABLE IF EXISTS api_keys;
DROP TABLE IF EXISTS sbc_addresses;
@@ -148,6 +152,20 @@ predefined_carrier_sid CHAR(36) NOT NULL,
PRIMARY KEY (predefined_sip_gateway_sid)
);
CREATE TABLE predefined_smpp_gateways
(
predefined_smpp_gateway_sid CHAR(36) NOT NULL UNIQUE ,
ipv4 VARCHAR(128) NOT NULL COMMENT 'ip address or DNS name of the gateway. ',
port INTEGER NOT NULL DEFAULT 2775 COMMENT 'smpp signaling port',
inbound BOOLEAN NOT NULL COMMENT 'if true, whitelist this IP to allow inbound SMS from the gateway',
outbound BOOLEAN NOT NULL COMMENT 'i',
netmask INTEGER NOT NULL DEFAULT 32,
is_primary BOOLEAN NOT NULL DEFAULT 1,
use_tls BOOLEAN DEFAULT 0,
predefined_carrier_sid CHAR(36) NOT NULL,
PRIMARY KEY (predefined_smpp_gateway_sid)
);
CREATE TABLE products
(
product_sid CHAR(36) NOT NULL UNIQUE ,
@@ -174,6 +192,11 @@ stripe_product_id VARCHAR(56) NOT NULL,
PRIMARY KEY (account_offer_sid)
);
CREATE TABLE schema_version
(
version VARCHAR(16)
);
CREATE TABLE api_keys
(
api_key_sid CHAR(36) NOT NULL UNIQUE ,
@@ -420,6 +443,10 @@ CREATE INDEX predefined_sip_gateway_sid_idx ON predefined_sip_gateways (predefin
CREATE INDEX predefined_carrier_sid_idx ON predefined_sip_gateways (predefined_carrier_sid);
ALTER TABLE predefined_sip_gateways ADD FOREIGN KEY predefined_carrier_sid_idxfk (predefined_carrier_sid) REFERENCES predefined_carriers (predefined_carrier_sid);
CREATE INDEX predefined_smpp_gateway_sid_idx ON predefined_smpp_gateways (predefined_smpp_gateway_sid);
CREATE INDEX predefined_carrier_sid_idx ON predefined_smpp_gateways (predefined_carrier_sid);
ALTER TABLE predefined_smpp_gateways ADD FOREIGN KEY predefined_carrier_sid_idxfk_1 (predefined_carrier_sid) REFERENCES predefined_carriers (predefined_carrier_sid);
CREATE INDEX product_sid_idx ON products (product_sid);
CREATE INDEX account_product_sid_idx ON account_products (account_product_sid);
CREATE INDEX account_subscription_sid_idx ON account_products (account_subscription_sid);
@@ -545,4 +572,4 @@ ALTER TABLE accounts ADD FOREIGN KEY queue_event_hook_sid_idxfk (queue_event_hoo
ALTER TABLE accounts ADD FOREIGN KEY device_calling_application_sid_idxfk (device_calling_application_sid) REFERENCES applications (application_sid);
SET FOREIGN_KEY_CHECKS=1;
SET FOREIGN_KEY_CHECKS=0;

View File

@@ -9,6 +9,7 @@ networks:
services:
mysql:
image: mysql:5.7
platform: linux/x86_64
ports:
- "3360:3306"
environment:
@@ -123,7 +124,7 @@ services:
ipv4_address: 172.38.0.63
influxdb:
image: influxdb:1.8-alpine
image: influxdb:1.8
ports:
- "8086:8086"
networks: