Compare commits

..

15 Commits

Author SHA1 Message Date
akirilyuk
b31a8db9de do not resolve gather on barge in 2022-02-27 00:01:38 +01:00
akirilyuk
1d1388583b fix linting remove logs 2022-02-26 23:37:06 +01:00
akirilyuk
942c73e64b fix start listening 2022-02-26 23:32:00 +01:00
akirilyuk
de61cc1d3d enhance error log 2022-02-26 23:30:36 +01:00
akirilyuk
32ebc7017d add try catch in init speech 2022-02-26 23:30:12 +01:00
akirilyuk
87ef510dea more logs 2022-02-26 23:27:37 +01:00
akirilyuk
22440843d9 add more logs 2022-02-26 23:27:00 +01:00
akirilyuk
48fa3133ef add more logs 2022-02-26 23:22:13 +01:00
akirilyuk
a3352cc932 resolve with speech on barge in 2022-02-26 23:12:58 +01:00
akirilyuk
2de54ddedf start listening if not say nor play provided 2022-02-26 22:21:24 +01:00
akirilyuk
812b40fcb0 add possibility to not listen after gather say/play finished 2022-02-26 22:05:21 +01:00
akirilyuk
9a0e33e4f7 add listenAfterSpeech to gather spec 2022-02-26 21:57:09 +01:00
Dave Horton
c7e141abf1 gather: support for min/max digits and interdigit timeout 2022-02-26 15:30:35 -05:00
Dave Horton
e30701c4b4 bugfix: gather handles interim results from azure 2022-02-26 14:49:34 -05:00
Dave Horton
c5d392af6a add bargein support to gather 2022-02-26 14:41:21 -05:00
92 changed files with 4482 additions and 14002 deletions

View File

@@ -5,12 +5,12 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-20.04
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v2
- uses: actions/setup-node@v3 - uses: actions/setup-node@v1
with: with:
node-version: 16 node-version: 14
- run: npm ci - run: npm ci
- run: npm run jslint - run: npm run jslint
- run: docker pull drachtio/sipp - run: docker pull drachtio/sipp
@@ -20,5 +20,3 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: ${{ secrets.AWS_REGION }} AWS_REGION: ${{ secrets.AWS_REGION }}
MICROSOFT_REGION: ${{ secrets.MICROSOFT_REGION }}
MICROSOFT_API_KEY: ${{ secrets.MICROSOFT_API_KEY }}

View File

@@ -20,7 +20,7 @@ jobs:
if: github.event_name == 'push' if: github.event_name == 'push'
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v2
- name: Build image - name: Build image
run: docker build . --file Dockerfile --tag $IMAGE_NAME run: docker build . --file Dockerfile --tag $IMAGE_NAME

View File

@@ -1,23 +1,10 @@
FROM --platform=linux/amd64 node:18.12.1-alpine3.16 as base FROM node:17.4-slim
RUN apk --update --no-cache add --virtual .builds-deps build-base python3
WORKDIR /opt/app/ WORKDIR /opt/app/
COPY package.json ./
FROM base as build RUN npm install
RUN npm prune
COPY package.json package-lock.json ./ COPY . /opt/app
RUN npm ci
COPY . .
FROM base
COPY --from=build /opt/app /opt/app/
ARG NODE_ENV ARG NODE_ENV
ENV NODE_ENV $NODE_ENV ENV NODE_ENV $NODE_ENV
CMD [ "node", "app.js" ] CMD [ "npm", "start" ]

View File

@@ -2,8 +2,6 @@
This application implements the core feature server of the jambones platform. This application implements the core feature server of the jambones platform.
> Note: If you are a developer looking to work on the code please read our [how-to for that](./docs/contributing.md).
## Configuration ## Configuration
Configuration is provided via environment variables: Configuration is provided via environment variables:
@@ -86,5 +84,7 @@ module.exports = {
``` ```
#### Running the test suite #### Running the test suite
The test suite currently only consists of JSON-parsing unit tests. A full end-to-end sip test suite should be added.
Please [see this]](./docs/contributing.md#run-the-regression-test-suite). ```
npm test
```

83
app.js
View File

@@ -11,29 +11,36 @@ assert.ok(process.env.JAMBONES_NETWORK_CIDR || process.env.K8S, 'missing JAMBONE
const Srf = require('drachtio-srf'); const Srf = require('drachtio-srf');
const srf = new Srf(); const srf = new Srf();
const tracer = require('./tracer')(process.env.JAMBONES_OTEL_SERVICE_NAME || 'jambonz-feature-server'); const PORT = process.env.HTTP_PORT || 3000;
const api = require('@opentelemetry/api'); const opts = {
srf.locals = {...srf.locals, otel: {tracer, api}}; timestamp: () => {return `, "time": "${new Date().toISOString()}"`;},
level: process.env.JAMBONES_LOGLEVEL || 'info'
const opts = {level: process.env.JAMBONES_LOGLEVEL || 'info'}; };
const pino = require('pino'); const logger = require('pino')(opts);
const logger = pino(opts, pino.destination({sync: false}));
const {LifeCycleEvents, FS_UUID_SET_NAME} = require('./lib/utils/constants'); const {LifeCycleEvents, FS_UUID_SET_NAME} = require('./lib/utils/constants');
const installSrfLocals = require('./lib/utils/install-srf-locals'); const installSrfLocals = require('./lib/utils/install-srf-locals');
installSrfLocals(srf, logger); installSrfLocals(srf, logger);
const { const {
initLocals, initLocals,
createRootSpan,
handleSipRec,
getAccountDetails, getAccountDetails,
normalizeNumbers, normalizeNumbers,
retrieveApplication, retrieveApplication,
invokeWebCallback invokeWebCallback
} = require('./lib/middleware')(srf, logger); } = require('./lib/middleware')(srf, logger);
// HTTP
const express = require('express');
const helmet = require('helmet');
const app = express();
Object.assign(app.locals, {
logger,
srf
});
const httpRoutes = require('./lib/http-routes');
const InboundCallSession = require('./lib/session/inbound-call-session'); const InboundCallSession = require('./lib/session/inbound-call-session');
const SipRecCallSession = require('./lib/session/siprec-call-session');
if (process.env.DRACHTIO_HOST) { if (process.env.DRACHTIO_HOST) {
srf.connect({host: process.env.DRACHTIO_HOST, port: process.env.DRACHTIO_PORT, secret: process.env.DRACHTIO_SECRET }); srf.connect({host: process.env.DRACHTIO_HOST, port: process.env.DRACHTIO_PORT, secret: process.env.DRACHTIO_SECRET });
@@ -55,21 +62,31 @@ if (process.env.NODE_ENV === 'test') {
srf.use('invite', [ srf.use('invite', [
initLocals, initLocals,
createRootSpan,
handleSipRec,
getAccountDetails, getAccountDetails,
normalizeNumbers, normalizeNumbers,
retrieveApplication, retrieveApplication,
invokeWebCallback invokeWebCallback
]); ]);
srf.invite(async(req, res) => { srf.invite((req, res) => {
const isSipRec = !!req.locals.siprec; const session = new InboundCallSession(req, res);
const session = isSipRec ? new SipRecCallSession(req, res) : new InboundCallSession(req, res);
if (isSipRec) await session.answerSipRecCall();
session.exec(); session.exec();
}); });
// HTTP
app.use(helmet());
app.use(helmet.hidePoweredBy());
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use('/', httpRoutes);
app.use((err, req, res, next) => {
logger.error(err, 'burped error');
res.status(err.status || 500).json({msg: err.message});
});
const httpServer = app.listen(PORT);
logger.info(`listening for HTTP requests on port ${PORT}, serviceUrl is ${srf.locals.serviceUrl}`);
const sessionTracker = srf.locals.sessionTracker = require('./lib/session/session-tracker'); const sessionTracker = srf.locals.sessionTracker = require('./lib/session/session-tracker');
sessionTracker.on('idle', () => { sessionTracker.on('idle', () => {
if (srf.locals.lifecycleEmitter.operationalState === LifeCycleEvents.ScaleIn) { if (srf.locals.lifecycleEmitter.operationalState === LifeCycleEvents.ScaleIn) {
@@ -77,30 +94,19 @@ sessionTracker.on('idle', () => {
srf.locals.lifecycleEmitter.scaleIn(); srf.locals.lifecycleEmitter.scaleIn();
} }
}); });
const getCount = () => sessionTracker.count; const getCount = () => sessionTracker.count;
const healthCheck = require('@jambonz/http-health-check'); const healthCheck = require('@jambonz/http-health-check');
let httpServer; healthCheck({app, logger, path: '/', fn: getCount});
const createHttpListener = require('./lib/utils/http-listener');
createHttpListener(logger, srf)
.then(({server, app}) => {
httpServer = server;
healthCheck({app, logger, path: '/', fn: getCount});
return {server, app};
})
.catch((err) => {
logger.error(err, 'Error creating http listener');
});
setInterval(() => { setInterval(() => {
srf.locals.stats.gauge('fs.sip.calls.count', sessionTracker.count); srf.locals.stats.gauge('fs.sip.calls.count', sessionTracker.count);
}, 20000); }, 5000);
const disconnect = () => { const disconnect = () => {
return new Promise ((resolve) => { return new Promise ((resolve) => {
httpServer?.on('close', resolve); httpServer.on('close', resolve);
httpServer?.close(); httpServer.close();
srf.disconnect(); srf.disconnect();
srf.locals.mediaservers.forEach((ms) => ms.disconnect()); srf.locals.mediaservers.forEach((ms) => ms.disconnect());
}); });
@@ -118,17 +124,4 @@ function handle(signal) {
srf.locals.disabled = true; srf.locals.disabled = true;
} }
if (process.env.JAMBONZ_CLEANUP_INTERVAL_MINS) {
const {clearFiles} = require('./lib/utils/cron-jobs');
/* cleanup orphaned files or channels every so often */
setInterval(async() => {
try {
await clearFiles();
} catch (err) {
logger.error({err}, 'app.js: error clearing files');
}
}, 1000 * 60 * (process.env.JAMBONZ_CLEANUP_INTERVAL_MINS || 60));
}
module.exports = {srf, logger, disconnect}; module.exports = {srf, logger, disconnect};

View File

@@ -1,52 +0,0 @@
{
"en-US": [
"call has been forwarded",
"at the beep",
"at the tone",
"leave a message",
"leave me a message",
"not available right now",
"not available to take your call",
"can't take your call",
"I will get back to you",
"I'll get back to you",
"we will get back to you",
"we are unable",
"we are not available"
],
"es-ES": [
"le pasamos la llamada",
"después del bip",
"después del tono",
"deja un mensaje",
"déjame un mensaje",
"no estamos disponibles",
"no estoy disponible",
"ahora no puedo",
"no puedo contestar",
"no le puedo contestar",
"me pondré en contacto",
"nos pondremos en contacto",
"ahora no estamos disponibles",
"no estamos disponibles"
],
"ca-ES": [
"passem la seva trucada",
"després del bip",
"després del to",
"deixi un missatge",
"deixa un missatge",
"deixim un missatge",
"no estem disponibles",
"no estem a l'oficina",
"no estic disponible",
"ara no puc",
"no puc contestar",
"no puc respondre",
"no li puc respondre",
"em posaré en contacte",
"ens posarem en contacto",
"ara no estem disponibles",
"no hi som"
]
}

View File

@@ -1,123 +0,0 @@
# Contributors are welcome!
So, you want to hack on jambonz? Maybe add some features, maybe help fix some bugs? Awesome, welcome aboard!
This brief document should get you started. Here you will find instructions showing how to set up your laptop to run the regression test suite (which you should always run before committing any changes), as well as some basic info on the structure of the code.
## Getting oriented
First of all, you are in the right place to begin hacking on jambonz. The jambonz-feature-server app is kinda the center of the universe for jambonz. Most of the core logic in jambonz is implemented here: things like the [webhook verbs](../lib/tasks), [session management](../lib/session), and the [client-side webhook implementation](../lib/utils/http-requestor.js). A common thing you might want to do, for instance, is to add support for an all-new verb, and this code base is where would do that.
This jambonz-feature-server app works together quite closely with a [drachtio server](https://github.com/drachtio/drachtio-server) and a Freeswitch. In fact, these three components are bundled together into a single VM/instance (or a Deployment, in Kubernetes) that we more generally refer to as "Feature Server". The Feature Server is a horizontally-scalable unit that is deployed behind the public-facing SBC elements of a jambonz cluster (the SBC is itself a separately scalable unit). The drachtio-server handles the SIP signaling, the Freeswitch handles media operations and speech vendor integration, and the jambonz-feature-server app orchestrates all of it via the use of [drachtio-srf](https://github.com/drachtio/drachtio-srf) and [drachtio-fsmrf](https://github.com/drachtio/drachtio-fsmrf).
## How to do things
First of all, please join our [slack channel](https://joinslack.jambonz.org) in order to coordinate with us on the work, i.e. to notify us of what you are doing and make sure that no one else is already working on the same thing.
To prepare to make changes, please fork the repo to your own Github account, make changes, test them on your own running jambonz cluster, then run the regression test suite and lint check before giving us a PR.
### lint
We have some opinionated conventions that you must follow - see our [eslintrc.json](../.eslintrc.json) for details. Make sure your code passes by running:
```bash
npm run jslint
```
### test suite
#### Generate speech credentials and create run-tests.sh
The test suite also requires you to provide speech credentials for both GCP and AWS. You will want to create a new file named `run-tests.sh` in the project folder. Make the file executable and then copy in the text below, substituting your speech credentials where indicated:
```bash
#!/bin/bash
GCP_JSON_KEY='{"type":"service_account","project_id":"...etc"}' \
AWS_ACCESS_KEY_ID='your-aws-access-key-id' \
AWS_SECRET_ACCESS_KEY='your-aws-secret-access-key' \
AWS_REGION='us-east-1' \
JWT_SECRET='foobar' \
npm test
```
>> Note: The project's .gitignore file prevents this file from being sent to Github, so you do not need to worry about exposing your credentials. Just make sure you name if run-tests.sh and create it in the project folder
The GCP credential is the JSON service key in stringified format.
#### Install Docker
The test suite ralso equires [Docker](https://www.docker.com/) and docker-compose to be installed on your laptop. Docker is used to set up a network with all of the elements required to test the jambonz-feature-server in a black-box type of fashion.
Once you have docker installed, you can optionally make sure everything Docker-wise is working properly by running this command from the project folder:
```bash
docker-compose -f test/docker-compose-testbed.yaml up -d
```
This may take several minutes to complete, mainly because the mysql schema needs to be installed and seeded, but if successful the output should look like this:
```bash
$ docker-compose -f test/docker-compose-testbed.yaml up -d
Creating network "test_fs" with driver "bridge"
Creating test_webhook-transcribe_1 ... done
Creating test_webhook-decline_1 ... done
Creating test_mysql_1 ... done
Creating test_docker-host_1 ... done
Creating test_webhook-gather_1 ... done
Creating test_webhook-say_1 ... done
Creating test_freeswitch_1 ... done
Creating test_influxdb_1 ... done
Creating test_redis_1 ... done
Creating test_drachtio_1 ... done
```
At that point, you can run `docker ps` to see all of the containers running
```bash
docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
abbc3594f390 drachtio/drachtio-server:latest "/entrypoint.sh drac…" About a minute ago Up About a minute 0.0.0.0:9060->9022/tcp test_drachtio_1
1f384a274f87 redis:5-alpine "docker-entrypoint.s…" 2 minutes ago Up 2 minutes 0.0.0.0:16379->6379/tcp test_redis_1
78d0bb6ec9b1 influxdb:1.8 "/entrypoint.sh infl…" 2 minutes ago Up 2 minutes 0.0.0.0:8086->8086/tcp test_influxdb_1
9616ff790709 jambonz/webhook-test-scaffold:latest "/entrypoint.sh" 2 minutes ago Up 2 minutes 0.0.0.0:3102->3000/tcp test_webhook-gather_1
7323ab273ff4 drachtio/drachtio-freeswitch-mrf:v1.10.1-full "/entrypoint.sh free…" 2 minutes ago Up 2 minutes (healthy) 0.0.0.0:8022->8021/tcp test_freeswitch_1
e45e7d28dbc7 mysql:5.7 "docker-entrypoint.s…" 2 minutes ago Up 2 minutes (healthy) 33060/tcp, 0.0.0.0:3360->3306/tcp test_mysql_1
b626e5f3067e qoomon/docker-host "/entrypoint.sh" 2 minutes ago Up 2 minutes test_docker-host_1
b0a94b5e8941 jambonz/webhook-test-scaffold:latest "/entrypoint.sh" 2 minutes ago Up 2 minutes 0.0.0.0:3101->3000/tcp test_webhook-say_1
f80adda48eb5 jambonz/webhook-test-scaffold:latest "/entrypoint.sh" 2 minutes ago Up 2 minutes 0.0.0.0:3103->3000/tcp test_webhook-transcribe_1
223db4a9c670 jambonz/webhook-test-scaffold:latest "/entrypoint.sh" 2 minutes ago Up 2 minutes 0.0.0.0:3100->3000/tcp test_webhook-decline_1
```
#### Run the regression test suite
The test suite has a dependency that the mysql client is installed on your laptop/machine where the test will be run. This is needed in order to seed the mysql database that is running in the docker network.
Assuming you have installed the mysql client, and done the above steps, you should now be able to run the tests:
```bash
./run-tests.sh
```
If the docker network has not been started (as described above) it will start now, and this will take a minute or two. Otherwise, the test suite will start running immediately.
In evaluating the test results, be advised that the output is fairly verbose, and also in the process of shutting down once the tests are complete you will see a bunch of errors from redis (`@jambonz/realtimedb-helpers - redis error`). You can ignore these errors, they are just spit out by jambonz-feature-server as the test environment is torn down and it tries and fails to reconnect to redis.
The final output will indicate the number of tests run and passed:
```bash
1..28
# tests 28
# pass 28
# ok
```
#### Adding your own tests
Running a successful regression test means you haven't broken anything - Great!
It doesn't, of course, mean that your shiny new feature or bugfix works. Adding a new test case to the suite is (unfortunately) non-trivial. We will add more documentation in the future with a how-to guide on that, but be advised it does require knowledge of the SIP protocol and the [SIPp](http://sipp.sourceforge.net/doc/reference.html) tool.
For now, if you are unable to add tests to the regression suite, please do test your feature as thoroughly as you can on your own jambonz cluster before giving us a pull request.

View File

@@ -3,23 +3,21 @@ const makeTask = require('../../tasks/make_task');
const RestCallSession = require('../../session/rest-call-session'); const RestCallSession = require('../../session/rest-call-session');
const CallInfo = require('../../session/call-info'); const CallInfo = require('../../session/call-info');
const {CallDirection, CallStatus} = require('../../utils/constants'); const {CallDirection, CallStatus} = require('../../utils/constants');
const uuidv4 = require('uuid-random'); const { v4: uuidv4 } = require('uuid');
const SipError = require('drachtio-srf').SipError; const SipError = require('drachtio-srf').SipError;
const sysError = require('./error'); const sysError = require('./error');
const HttpRequestor = require('../../utils/http-requestor'); const HttpRequestor = require('../../utils/http-requestor');
const WsRequestor = require('../../utils/ws-requestor'); const WsRequestor = require('../../utils/ws-requestor');
const RootSpan = require('../../utils/call-tracer');
const dbUtils = require('../../utils/db-utils'); const dbUtils = require('../../utils/db-utils');
router.post('/', async(req, res) => { router.post('/', async(req, res) => {
const {logger} = req.app.locals; const {logger} = req.app.locals;
const accountSid = req.body.account_sid;
const {srf} = require('../../..');
logger.debug({body: req.body}, 'got createCall request'); logger.debug({body: req.body}, 'got createCall request');
try { try {
let uri, cs, to; let uri, cs, to;
const restDial = makeTask(logger, {'rest:dial': req.body}); const restDial = makeTask(logger, {'rest:dial': req.body});
const {srf} = require('../../..');
const {lookupAccountDetails} = dbUtils(logger, srf); const {lookupAccountDetails} = dbUtils(logger, srf);
const {getSBC, getFreeswitch} = srf.locals; const {getSBC, getFreeswitch} = srf.locals;
const sbcAddress = getSBC(); const sbcAddress = getSBC();
@@ -41,8 +39,7 @@ router.post('/', async(req, res) => {
'X-Jambonz-Routing': target.type, 'X-Jambonz-Routing': target.type,
'X-Jambonz-FS-UUID': srf.locals.fsUUID, 'X-Jambonz-FS-UUID': srf.locals.fsUUID,
'X-Call-Sid': callSid, 'X-Call-Sid': callSid,
'X-Account-Sid': accountSid, 'X-Account-Sid': req.body.account_sid
...(restDial.fromHost && {'X-Preferred-From-Host': restDial.fromHost})
}; };
switch (target.type) { switch (target.type) {
@@ -51,7 +48,7 @@ router.post('/', async(req, res) => {
uri = `sip:${target.number}@${sbcAddress}`; uri = `sip:${target.number}@${sbcAddress}`;
to = target.number; to = target.number;
if ('teams' === target.type) { if ('teams' === target.type) {
const obj = await lookupTeamsByAccount(accountSid); const obj = await lookupTeamsByAccount(req.body.account_sid);
if (!obj) throw new Error('dial to ms teams not allowed; account must first be configured with teams info'); if (!obj) throw new Error('dial to ms teams not allowed; account must first be configured with teams info');
Object.assign(opts.headers, { Object.assign(opts.headers, {
'X-MS-Teams-FQDN': obj.ms_teams_fqdn, 'X-MS-Teams-FQDN': obj.ms_teams_fqdn,
@@ -75,16 +72,6 @@ router.post('/', async(req, res) => {
break; break;
} }
if (target.type === 'phone' && target.trunk) {
const {lookupCarrier} = dbUtils(this.logger, srf);
const voip_carrier_sid = await lookupCarrier(req.body.account_sid, target.trunk);
logger.info(
`createCall: selected ${voip_carrier_sid} for requested carrier: ${target.trunk || 'unspecified'})`);
if (voip_carrier_sid) {
opts.headers['X-Requested-Carrier-Sid'] = voip_carrier_sid;
}
}
/* create endpoint for outdial */ /* create endpoint for outdial */
const ms = getFreeswitch(); const ms = getFreeswitch();
if (!ms) throw new Error('no available Freeswitch for outbound call creation'); if (!ms) throw new Error('no available Freeswitch for outbound call creation');
@@ -118,39 +105,23 @@ router.post('/', async(req, res) => {
* attach our requestor and notifier objects * attach our requestor and notifier objects
* these will be used for all http requests we make during this call * these will be used for all http requests we make during this call
*/ */
if ('WS' === app.call_hook?.method || /^wss?:/.test(app.call_hook.url)) { if ('WS' === app.call_hook?.method) {
logger.debug({call_hook: app.call_hook}, 'creating websocket for call hook');
app.requestor = new WsRequestor(logger, account.account_sid, app.call_hook, account.webhook_secret) ; app.requestor = new WsRequestor(logger, account.account_sid, app.call_hook, account.webhook_secret) ;
if (app.call_hook.url === app.call_status_hook.url || !app.call_status_hook?.url) { app.notifier = app.requestor;
logger.debug('reusing websocket for call status hook');
app.notifier = app.requestor;
}
} }
else { else {
logger.debug({call_hook: app.call_hook}, 'creating http client for call hook');
app.requestor = new HttpRequestor(logger, account.account_sid, app.call_hook, account.webhook_secret); app.requestor = new HttpRequestor(logger, account.account_sid, app.call_hook, account.webhook_secret);
} if (app.call_status_hook) app.notifier = new HttpRequestor(logger, account.account_sid, app.call_status_hook,
if (!app.notifier && app.call_status_hook) { account.webhook_secret);
app.notifier = new HttpRequestor(logger, account.account_sid, app.call_status_hook, account.webhook_secret); else app.notifier = {request: () => {}};
logger.debug({call_hook: app.call_hook}, 'creating http client for call status hook');
}
else if (!app.notifier) {
logger.debug('creating null call status hook');
app.notifier = {request: () => {}, close: () => {}};
} }
/* now launch the outdial */ /* now launch the outdial */
try { try {
const dlg = await srf.createUAC(uri, {...opts, followRedirects: true, keepUriOnRedirect: true}, { const dlg = await srf.createUAC(uri, {...opts, followRedirects: true, keepUriOnRedirect: true}, {
cbRequest: (err, inviteReq) => { cbRequest: (err, inviteReq) => {
/* in case of 302 redirect, this gets called twice, ignore the second /* in case of 302 redirect, this gets called twice, ignore the second */
except to update the req so that it can later be canceled if need be if (res.headersSent) return;
*/
if (res.headersSent) {
logger.info(`create-call: got redirect, updating request to new call-id ${req.get('Call-ID')}`);
if (cs) cs.req = inviteReq;
return;
}
if (err) { if (err) {
logger.error(err, 'createCall Error creating call'); logger.error(err, 'createCall Error creating call');
@@ -158,22 +129,9 @@ router.post('/', async(req, res) => {
return; return;
} }
inviteReq.srf = srf; inviteReq.srf = srf;
inviteReq.locals = {
...(inviteReq || {}),
callSid,
application_sid: app.application_sid
};
/* ok our outbound INVITE is in flight */ /* ok our outbound INVITE is in flight */
const tasks = [restDial]; const tasks = [restDial];
const rootSpan = new RootSpan('rest-call', inviteReq);
sipLogger = logger.child({
callSid,
callId: inviteReq.get('Call-ID'),
accountSid,
traceId: rootSpan.traceId
});
app.requestor.logger = app.notifier.logger = sipLogger;
const callInfo = new CallInfo({ const callInfo = new CallInfo({
direction: CallDirection.Outbound, direction: CallDirection.Outbound,
req: inviteReq, req: inviteReq,
@@ -181,26 +139,18 @@ router.post('/', async(req, res) => {
tag: app.tag, tag: app.tag,
callSid, callSid,
accountSid: req.body.account_sid, accountSid: req.body.account_sid,
applicationSid: app.application_sid, applicationSid: app.application_sid
traceId: rootSpan.traceId
});
cs = new RestCallSession({
logger: sipLogger,
application: app,
srf,
req: inviteReq,
ep,
tasks,
callInfo,
accountInfo,
rootSpan
}); });
cs = new RestCallSession({logger, application: app, srf, req: inviteReq, ep, tasks, callInfo, accountInfo});
cs.exec(req); cs.exec(req);
res.status(201).json({sid: cs.callSid, callId: inviteReq.get('Call-ID')}); res.status(201).json({sid: cs.callSid});
sipLogger.info({sid: cs.callSid, callId: inviteReq.get('Call-ID')}, sipLogger = logger.child({
`outbound REST call attempt to ${JSON.stringify(target)} has been sent`); callSid: cs.callSid,
callId: callInfo.callId
});
sipLogger.info(`outbound REST call attempt to ${JSON.stringify(target)} has been sent`);
}, },
cbProvisional: (prov) => { cbProvisional: (prov) => {
const callStatus = prov.body ? CallStatus.EarlyMedia : CallStatus.Ringing; const callStatus = prov.body ? CallStatus.EarlyMedia : CallStatus.Ringing;
@@ -210,11 +160,7 @@ router.post('/', async(req, res) => {
} }
}); });
connectStream(dlg.remote.sdp); connectStream(dlg.remote.sdp);
cs.emit('callStatusChange', { cs.emit('callStatusChange', {callStatus: CallStatus.InProgress, sipStatus: 200});
callStatus: CallStatus.InProgress,
sipStatus: 200,
sipReason: 'OK'
});
restDial.emit('callStatus', 200); restDial.emit('callStatus', 200);
restDial.emit('connect', dlg); restDial.emit('connect', dlg);
} }
@@ -225,23 +171,14 @@ router.post('/', async(req, res) => {
else if (487 === err.status) callStatus = CallStatus.NoAnswer; else if (487 === err.status) callStatus = CallStatus.NoAnswer;
if (sipLogger) sipLogger.info(`REST outdial failed with ${err.status}`); if (sipLogger) sipLogger.info(`REST outdial failed with ${err.status}`);
else console.log(`REST outdial failed with ${err.status}`); else console.log(`REST outdial failed with ${err.status}`);
if (cs) cs.emit('callStatusChange', { if (cs) cs.emit('callStatusChange', {callStatus, sipStatus: err.status});
callStatus,
sipStatus: err.status,
sipReason: err.reason
});
} }
else { else {
if (cs) cs.emit('callStatusChange', { if (cs) cs.emit('callStatusChange', {callStatus, sipStatus: 500});
callStatus,
sipStatus: 500,
sipReason: 'Internal Server Error'
});
if (sipLogger) sipLogger.error({err}, 'REST outdial failed'); if (sipLogger) sipLogger.error({err}, 'REST outdial failed');
else console.error(err); else console.error(err);
} }
ep.destroy(); ep.destroy();
setTimeout(restDial.kill.bind(restDial), 5000);
} }
} catch (err) { } catch (err) {
sysError(logger, res, err); sysError(logger, res, err);

View File

@@ -34,7 +34,6 @@ router.post('/:partner', async(req, res) => {
carrier: req.params.partner, carrier: req.params.partner,
messageSid: app.messageSid, messageSid: app.messageSid,
accountSid: app.accountSid, accountSid: app.accountSid,
serviceProviderSid: account.service_provider_sid,
applicationSid: app.applicationSid, applicationSid: app.applicationSid,
from: req.body.from, from: req.body.from,
to: req.body.to, to: req.body.to,

View File

@@ -12,9 +12,6 @@ function retrieveCallSession(callSid, opts) {
throw new DbErrorBadRequest('call_status_hook can be updated only when call_hook is also being updated'); throw new DbErrorBadRequest('call_status_hook can be updated only when call_hook is also being updated');
} }
const cs = sessionTracker.get(callSid); const cs = sessionTracker.get(callSid);
if (!cs) {
throw new DbErrorUnprocessableRequest('call session is gone');
}
if (opts.call_status === CallStatus.Completed && !cs.hasStableDialog) { if (opts.call_status === CallStatus.Completed && !cs.hasStableDialog) {
throw new DbErrorUnprocessableRequest('current call state is incompatible with requested action'); throw new DbErrorUnprocessableRequest('current call state is incompatible with requested action');
@@ -48,18 +45,8 @@ router.post('/:callSid', async(req, res) => {
logger.info(`updateCall: callSid not found ${callSid}`); logger.info(`updateCall: callSid not found ${callSid}`);
return res.sendStatus(404); return res.sendStatus(404);
} }
res.sendStatus(202);
if (req.body.sip_request) { cs.updateCall(req.body, callSid);
const response = await cs.updateCall(req.body, callSid);
res.status(200).json({
status: response.status,
reason: response.reason
});
}
else {
res.sendStatus(202);
cs.updateCall(req.body, callSid);
}
} catch (err) { } catch (err) {
sysError(logger, res, err); sysError(logger, res, err);
} }

View File

@@ -1,6 +1,5 @@
const uuidv4 = require('uuid-random'); const { v4: uuidv4 } = require('uuid');
const {CallDirection, AllowedSipRecVerbs} = require('./utils/constants'); const {CallDirection} = require('./utils/constants');
const {parseSiprecPayload} = require('./utils/siprec-utils');
const CallInfo = require('./session/call-info'); const CallInfo = require('./session/call-info');
const HttpRequestor = require('./utils/http-requestor'); const HttpRequestor = require('./utils/http-requestor');
const WsRequestor = require('./utils/ws-requestor'); const WsRequestor = require('./utils/ws-requestor');
@@ -8,8 +7,6 @@ const makeTask = require('./tasks/make_task');
const parseUri = require('drachtio-srf').parseUri; const parseUri = require('drachtio-srf').parseUri;
const normalizeJambones = require('./utils/normalize-jambones'); const normalizeJambones = require('./utils/normalize-jambones');
const dbUtils = require('./utils/db-utils'); const dbUtils = require('./utils/db-utils');
const RootSpan = require('./utils/call-tracer');
const listTaskNames = require('./utils/summarize-tasks');
module.exports = function(srf, logger) { module.exports = function(srf, logger) {
const { const {
@@ -19,25 +16,16 @@ module.exports = function(srf, logger) {
lookupAppByRealm, lookupAppByRealm,
lookupAppByTeamsTenant lookupAppByTeamsTenant
} = srf.locals.dbHelpers; } = srf.locals.dbHelpers;
const {
writeAlerts,
AlertType
} = srf.locals;
const {lookupAccountDetails} = dbUtils(logger, srf); const {lookupAccountDetails} = dbUtils(logger, srf);
function initLocals(req, res, next) { function initLocals(req, res, next) {
const callId = req.get('Call-ID');
logger.info({callId}, 'new incoming call');
if (!req.has('X-Account-Sid')) {
logger.info('getAccountDetails - rejecting call due to missing X-Account-Sid header');
return res.send(500);
}
const callSid = req.has('X-Retain-Call-Sid') ? req.get('X-Retain-Call-Sid') : uuidv4(); const callSid = req.has('X-Retain-Call-Sid') ? req.get('X-Retain-Call-Sid') : uuidv4();
const account_sid = req.get('X-Account-Sid'); req.locals = {
req.locals = {callSid, account_sid, callId}; callSid,
logger: logger.child({callId: req.get('Call-ID'), callSid})
};
if (req.has('X-Application-Sid')) { if (req.has('X-Application-Sid')) {
const application_sid = req.get('X-Application-Sid'); const application_sid = req.get('X-Application-Sid');
logger.debug(`got application from X-Application-Sid header: ${application_sid}`); req.locals.logger.debug(`got application from X-Application-Sid header: ${application_sid}`);
req.locals.application_sid = application_sid; req.locals.application_sid = application_sid;
} }
if (req.has('X-Authenticated-User')) req.locals.originatingUser = req.get('X-Authenticated-User'); if (req.has('X-Authenticated-User')) req.locals.originatingUser = req.get('X-Authenticated-User');
@@ -46,80 +34,19 @@ module.exports = function(srf, logger) {
next(); next();
} }
function createRootSpan(req, res, next) {
const {callId, callSid, account_sid} = req.locals;
const rootSpan = new RootSpan('incoming-call', req);
const traceId = rootSpan.traceId;
req.locals = {
...req.locals,
traceId,
logger: logger.child({
callId,
callSid,
accountSid: account_sid,
callingNumber: req.callingNumber,
calledNumber: req.calledNumber,
traceId}),
rootSpan
};
/**
* end the span on final failure or cancel from caller;
* otherwise it will be closed when sip dialog is destroyed
*/
req.once('cancel', () => {
rootSpan.setAttributes({finalStatus: 487});
rootSpan.end();
});
res.once('finish', () => {
rootSpan.setAttributes({finalStatus: res.statusCode});
res.statusCode >= 300 && rootSpan.end();
});
next();
}
const handleSipRec = async(req, res, next) => {
if (Array.isArray(req.payload) && req.payload.length > 1) {
const {callId, logger} = req.locals;
logger.debug({payload: req.payload}, 'handling siprec call');
try {
const sdp = req.payload
.find((p) => p.type === 'application/sdp')
.content;
const {sdp1, sdp2, ...metadata} = await parseSiprecPayload(req, logger);
req.locals.calledNumber = metadata.caller.number;
req.locals.callingNumber = metadata.callee.number;
req.locals = {
...req.locals,
siprec: {
metadata,
sdp1,
sdp2
}
};
logger.info({callId, metadata, sdp}, 'successfully parsed SIPREC payload');
} catch (err) {
logger.info({callId}, 'Error parsing multipart payload');
return res.send(503);
}
}
next();
};
/** /**
* retrieve account information for the incoming call * retrieve account information for the incoming call
*/ */
async function getAccountDetails(req, res, next) { async function getAccountDetails(req, res, next) {
const {rootSpan, account_sid} = req.locals;
const {span} = rootSpan.startChildSpan('lookupAccountDetails'); if (!req.has('X-Account-Sid')) {
logger.info('getAccountDetails - rejecting call due to missing X-Account-Sid header');
return res.send(500);
}
const account_sid = req.locals.account_sid = req.get('X-Account-Sid');
try { try {
req.locals.accountInfo = await lookupAccountDetails(account_sid); req.locals.accountInfo = await lookupAccountDetails(account_sid);
req.locals.service_provider_sid = req.locals.accountInfo?.account?.service_provider_sid;
span.end();
if (!req.locals.accountInfo.account.is_active) { if (!req.locals.accountInfo.account.is_active) {
logger.info(`Account is inactive or suspended ${account_sid}`); logger.info(`Account is inactive or suspended ${account_sid}`);
// TODO: alert // TODO: alert
@@ -128,7 +55,6 @@ module.exports = function(srf, logger) {
logger.debug({accountInfo: req.locals?.accountInfo?.account}, `retrieved account info for ${account_sid}`); logger.debug({accountInfo: req.locals?.accountInfo?.account}, `retrieved account info for ${account_sid}`);
next(); next();
} catch (err) { } catch (err) {
span.end();
logger.info({err}, `Error retrieving account details for account ${account_sid}`); logger.info({err}, `Error retrieving account details for account ${account_sid}`);
res.send(503, {headers: {'X-Reason': `No Account exists for sid ${account_sid}`}}); res.send(503, {headers: {'X-Reason': `No Account exists for sid ${account_sid}`}});
} }
@@ -138,10 +64,7 @@ module.exports = function(srf, logger) {
* Within the system, we deal with E.164 numbers _without_ the leading '+ * Within the system, we deal with E.164 numbers _without_ the leading '+
*/ */
function normalizeNumbers(req, res, next) { function normalizeNumbers(req, res, next) {
const {logger, siprec} = req.locals; const logger = req.locals.logger;
if (siprec) return next();
Object.assign(req.locals, { Object.assign(req.locals, {
calledNumber: req.calledNumber, calledNumber: req.calledNumber,
callingNumber: req.callingNumber callingNumber: req.callingNumber
@@ -162,8 +85,8 @@ module.exports = function(srf, logger) {
* Given the dialed DID/phone number, retrieve the application to invoke * Given the dialed DID/phone number, retrieve the application to invoke
*/ */
async function retrieveApplication(req, res, next) { async function retrieveApplication(req, res, next) {
const {logger, accountInfo, account_sid, rootSpan} = req.locals; const logger = req.locals.logger;
const {span} = rootSpan.startChildSpan('lookupApplication'); const {accountInfo, account_sid} = req.locals;
try { try {
let app; let app;
if (req.locals.application_sid) app = await lookupAppBySid(req.locals.application_sid); if (req.locals.application_sid) app = await lookupAppBySid(req.locals.application_sid);
@@ -207,11 +130,6 @@ module.exports = function(srf, logger) {
} }
} }
span.setAttributes({
'app.hook': app?.call_hook?.url,
'application_sid': req.locals.application_sid
});
span.end();
if (!app || !app.call_hook || !app.call_hook.url) { if (!app || !app.call_hook || !app.call_hook.url) {
logger.info(`rejecting call to ${req.locals.calledNumber}: no application or webhook url`); logger.info(`rejecting call to ${req.locals.calledNumber}: no application or webhook url`);
return res.send(480, { return res.send(480, {
@@ -225,36 +143,29 @@ module.exports = function(srf, logger) {
* create a requestor that we will use for all http requests we make during the call. * create a requestor that we will use for all http requests we make during the call.
* also create a notifier for call status events (if not needed, its a no-op). * also create a notifier for call status events (if not needed, its a no-op).
*/ */
/* allow for caching data - when caching treat retrieved data as immutable */
const app2 = process.env.JAMBONES_MYSQL_REFRESH_TTL ? JSON.parse(JSON.stringify(app)) : app;
if ('WS' === app.call_hook?.method || if ('WS' === app.call_hook?.method ||
app.call_hook?.url.startsWith('ws://') || app.call_hook?.url.startsWith('wss://')) { app.call_hook?.url.startsWith('ws://') || app.call_hook?.url.startsWith('wss://')) {
app2.requestor = new WsRequestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret) ; app.requestor = new WsRequestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret) ;
app2.notifier = app.requestor; app.notifier = app.requestor;
app2.call_hook.method = 'WS'; app.call_hook.method = 'WS';
} }
else { else {
app2.requestor = new HttpRequestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret); app.requestor = new HttpRequestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret);
if (app.call_status_hook) app2.notifier = new HttpRequestor(logger, account_sid, app.call_status_hook, if (app.call_status_hook) app.notifier = new HttpRequestor(logger, account_sid, app.call_status_hook,
accountInfo.account.webhook_secret); accountInfo.account.webhook_secret);
else app2.notifier = {request: () => {}}; else app.notifier = {request: () => {}};
} }
req.locals.application = app2; req.locals.application = app;
const obj = Object.assign({}, app);
delete obj.requestor;
delete obj.notifier;
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const {call_hook, call_status_hook, ...appInfo} = app; // mask sensitive data like user/pass on webhook 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}`); logger.info({app: appInfo}, `retrieved application for incoming call to ${req.locals.calledNumber}`);
req.locals.callInfo = new CallInfo({ req.locals.callInfo = new CallInfo({req, app, direction: CallDirection.Inbound});
req,
app: app2,
direction: CallDirection.Inbound,
traceId: rootSpan.traceId
});
next(); next();
} catch (err) { } catch (err) {
span.end();
logger.error(err, `${req.get('Call-ID')} Error looking up application for ${req.calledNumber}`); logger.error(err, `${req.get('Call-ID')} Error looking up application for ${req.calledNumber}`);
res.send(500); res.send(500);
} }
@@ -265,76 +176,29 @@ module.exports = function(srf, logger) {
*/ */
async function invokeWebCallback(req, res, next) { async function invokeWebCallback(req, res, next) {
const logger = req.locals.logger; const logger = req.locals.logger;
const {rootSpan, siprec, application:app} = req.locals; const app = req.locals.application;
let span;
try { try {
if (app.tasks && !process.env.JAMBONES_MYSQL_REFRESH_TTL) {
if (app.tasks) {
app.tasks = normalizeJambones(logger, app.tasks).map((tdata) => makeTask(logger, tdata)); app.tasks = normalizeJambones(logger, app.tasks).map((tdata) => makeTask(logger, tdata));
if (0 === app.tasks.length) throw new Error('no application provided'); if (0 === app.tasks.length) throw new Error('no application provided');
return next(); return next();
} }
/* retrieve the application to execute for this inbound call */ /* retrieve the application to execute for this inbound call */
const params = Object.assign(['POST', 'WS'].includes(app.call_hook.method) ? {sip: req.msg} : {}, const params = Object.assign(['POST', 'WS'].includes(app.call_hook.method) ? {sip: req.msg} : {},
req.locals.callInfo, req.locals.callInfo);
{service_provider_sid: req.locals.service_provider_sid}, const json = await app.requestor.request('session:new', app.call_hook, params);
{
defaults: {
synthesizer: {
vendor: app.speech_synthesis_vendor,
language: app.speech_synthesis_language,
voice: app.speech_synthesis_voice
},
recognizer: {
vendor: app.speech_recognizer_vendor,
language: app.speech_recognizer_language
}
}
});
logger.debug({params}, 'sending initial webhook');
const obj = rootSpan.startChildSpan('performAppWebhook');
span = obj.span;
const b3 = rootSpan.getTracingPropagation();
const httpHeaders = b3 && {b3};
const json = await app.requestor.request('session:new', app.call_hook, params, httpHeaders);
app.tasks = normalizeJambones(logger, json).map((tdata) => makeTask(logger, tdata)); app.tasks = normalizeJambones(logger, json).map((tdata) => makeTask(logger, tdata));
span.setAttributes({
'http.statusCode': 200,
'app.tasks': listTaskNames(app.tasks)
});
span.end();
if (0 === app.tasks.length) throw new Error('no application provided'); if (0 === app.tasks.length) throw new Error('no application provided');
if (siprec) {
const tasks = app.tasks.filter((t) => AllowedSipRecVerbs.includes(t.name));
if (0 === tasks.length) {
logger.info({tasks: app.tasks}, 'no valid verbs in app found for an incoming siprec call');
throw new Error('invalid verbs for incoming siprec call');
}
if (tasks.length < app.tasks.length) {
logger.info('removing verbs that are not allowed for incoming siprec call');
app.tasks = tasks;
}
}
next(); next();
} catch (err) { } catch (err) {
span?.setAttributes({webhookStatus: err.statusCode});
span?.end();
writeAlerts({
account_sid: req.locals.account_sid,
target_sid: req.locals.callSid,
alert_type: AlertType.INVALID_APP_PAYLOAD,
message: `${err?.message}`.trim()
}).catch((err) => this.logger.info({err}, 'Error generating alert for parsing application'));
logger.info({err}, `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 || 'unknown'}}); res.send(480, {headers: {'X-Reason': err?.message || 'unknown'}});
app.requestor.close();
} }
} }
return { return {
initLocals, initLocals,
createRootSpan,
handleSipRec,
getAccountDetails, getAccountDetails,
normalizeNumbers, normalizeNumbers,
retrieveApplication, retrieveApplication,

View File

@@ -8,15 +8,14 @@ const CallSession = require('./call-session');
*/ */
class AdultingCallSession extends CallSession { class AdultingCallSession extends CallSession {
constructor({logger, application, singleDialer, tasks, callInfo, accountInfo, rootSpan}) { constructor({logger, application, singleDialer, tasks, callInfo, accountInfo}) {
super({ super({
logger, logger,
application, application,
srf: singleDialer.dlg.srf, srf: singleDialer.dlg.srf,
tasks, tasks,
callInfo, callInfo,
accountInfo, accountInfo
rootSpan
}); });
this.sd = singleDialer; this.sd = singleDialer;
@@ -31,25 +30,15 @@ class AdultingCallSession extends CallSession {
return this.sd.dlg; return this.sd.dlg;
} }
/**
* Note: this is not an error. It is only here to avoid an assert ("no setter for dlg")
* when there is a call in Session:_clearResources to null out dlg and ep
*/
set dlg(newDlg) {}
get ep() { get ep() {
return this.sd.ep; return this.sd.ep;
} }
/* see note above */
set ep(newEp) {}
get callSid() { get callSid() {
return this.callInfo.callSid; return this.callInfo.callSid;
} }
_callerHungup() {
}
} }
module.exports = AdultingCallSession; module.exports = AdultingCallSession;

View File

@@ -1,6 +1,6 @@
const {CallDirection, CallStatus} = require('../utils/constants'); const {CallDirection, CallStatus} = require('../utils/constants');
const parseUri = require('drachtio-srf').parseUri; const parseUri = require('drachtio-srf').parseUri;
const uuidv4 = require('uuid-random'); const { v4: uuidv4 } = require('uuid');
/** /**
* @classdesc Represents the common information for all calls * @classdesc Represents the common information for all calls
* that is provided in call status webhooks * that is provided in call status webhooks
@@ -10,8 +10,6 @@ class CallInfo {
let from ; let from ;
let srf; let srf;
this.direction = opts.direction; this.direction = opts.direction;
this.traceId = opts.traceId;
this.callTerminationBy = undefined;
if (opts.req) { if (opts.req) {
const u = opts.req.getParsedHeader('from'); const u = opts.req.getParsedHeader('from');
const uri = parseUri(u.uri); const uri = parseUri(u.uri);
@@ -29,7 +27,6 @@ class CallInfo {
this.to = req.calledNumber; this.to = req.calledNumber;
this.callId = req.get('Call-ID'); this.callId = req.get('Call-ID');
this.sipStatus = 100; this.sipStatus = 100;
this.sipReason = 'Trying';
this.callStatus = CallStatus.Trying; this.callStatus = CallStatus.Trying;
this.originatingSipIp = req.get('X-Forwarded-For'); this.originatingSipIp = req.get('X-Forwarded-For');
this.originatingSipTrunkName = req.get('X-Originating-Carrier'); this.originatingSipTrunkName = req.get('X-Originating-Carrier');
@@ -48,7 +45,6 @@ class CallInfo {
this.callId = req.get('Call-ID'); this.callId = req.get('Call-ID');
this.callStatus = CallStatus.Trying, this.callStatus = CallStatus.Trying,
this.sipStatus = 100; this.sipStatus = 100;
this.sipReason = 'Trying';
} }
else if (this.direction === CallDirection.None) { else if (this.direction === CallDirection.None) {
// outbound SMS // outbound SMS
@@ -69,7 +65,6 @@ class CallInfo {
this.callStatus = CallStatus.Trying, this.callStatus = CallStatus.Trying,
this.callId = req.get('Call-ID'); this.callId = req.get('Call-ID');
this.sipStatus = 100; this.sipStatus = 100;
this.sipReason = 'Trying';
this.from = from || req.callingNumber; this.from = from || req.callingNumber;
this.to = to; this.to = to;
if (tag) this._customerData = tag; if (tag) this._customerData = tag;
@@ -86,10 +81,9 @@ class CallInfo {
* @param {string} callStatus - current call status * @param {string} callStatus - current call status
* @param {number} sipStatus - current sip status * @param {number} sipStatus - current sip status
*/ */
updateCallStatus(callStatus, sipStatus, sipReason) { updateCallStatus(callStatus, sipStatus) {
this.callStatus = callStatus; this.callStatus = callStatus;
if (sipStatus) this.sipStatus = sipStatus; if (sipStatus) this.sipStatus = sipStatus;
if (sipReason) this.sipReason = sipReason;
} }
/** /**
@@ -112,15 +106,13 @@ class CallInfo {
to: this.to, to: this.to,
callId: this.callId, callId: this.callId,
sipStatus: this.sipStatus, sipStatus: this.sipStatus,
sipReason: this.sipReason,
callStatus: this.callStatus, callStatus: this.callStatus,
callerId: this.callerId, callerId: this.callerId,
accountSid: this.accountSid, accountSid: this.accountSid,
traceId: this.traceId,
applicationSid: this.applicationSid, applicationSid: this.applicationSid,
fsSipAddress: this.localSipAddress fsSipAddress: this.localSipAddress
}; };
['parentCallSid', 'originatingSipIp', 'originatingSipTrunkName', 'callTerminationBy'].forEach((prop) => { ['parentCallSid', 'originatingSipIp', 'originatingSipTrunkName'].forEach((prop) => {
if (this[prop]) obj[prop] = this[prop]; if (this[prop]) obj[prop] = this[prop];
}); });
if (typeof this.duration === 'number') obj.duration = this.duration; if (typeof this.duration === 'number') obj.duration = this.duration;

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ const CallSession = require('./call-session');
*/ */
class ConfirmCallSession extends CallSession { class ConfirmCallSession extends CallSession {
constructor({logger, application, dlg, ep, tasks, callInfo, accountInfo, memberId, confName, rootSpan}) { constructor({logger, application, dlg, ep, tasks, callInfo, accountInfo, memberId, confName}) {
super({ super({
logger, logger,
application, application,
@@ -18,8 +18,7 @@ class ConfirmCallSession extends CallSession {
callInfo, callInfo,
accountInfo, accountInfo,
memberId, memberId,
confName, confName
rootSpan
}); });
this.dlg = dlg; this.dlg = dlg;
this.ep = ep; this.ep = ep;
@@ -31,10 +30,6 @@ class ConfirmCallSession extends CallSession {
_clearResources() { _clearResources() {
} }
_callerHungup() {
}
} }
module.exports = ConfirmCallSession; module.exports = ConfirmCallSession;

View File

@@ -16,8 +16,7 @@ class InboundCallSession extends CallSession {
application: req.locals.application, application: req.locals.application,
callInfo: req.locals.callInfo, callInfo: req.locals.callInfo,
accountInfo: req.locals.accountInfo, accountInfo: req.locals.accountInfo,
tasks: req.locals.application.tasks, tasks: req.locals.application.tasks
rootSpan: req.locals.rootSpan
}); });
this.req = req; this.req = req;
this.res = res; this.res = res;
@@ -25,28 +24,17 @@ class InboundCallSession extends CallSession {
req.once('cancel', this._onCancel.bind(this)); req.once('cancel', this._onCancel.bind(this));
this.on('callStatusChange', this._notifyCallStatusChange.bind(this)); this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
this._notifyCallStatusChange({ this._notifyCallStatusChange({callStatus: CallStatus.Trying, sipStatus: 100});
callStatus: CallStatus.Trying,
sipStatus: 100,
sipReason: 'Trying'
});
} }
_onCancel() { _onCancel() {
this.rootSpan.setAttributes({'call.termination': 'caller abandoned'}); this._notifyCallStatusChange({callStatus: CallStatus.NoAnswer, sipStatus: 487});
this.callInfo.callTerminationBy = 'caller';
this._notifyCallStatusChange({
callStatus: CallStatus.NoAnswer,
sipStatus: 487,
sipReason: 'Request Terminated'
});
this._callReleased(); this._callReleased();
} }
_onTasksDone() { _onTasksDone() {
if (!this.res.finalResponseSent) { if (!this.res.finalResponseSent) {
if (this._mediaServerFailure) { if (this._mediaServerFailure) {
this.rootSpan.setAttributes({'call.termination': 'media server failure'});
this.logger.info('InboundCallSession:_onTasksDone generating 480 due to media server failure'); this.logger.info('InboundCallSession:_onTasksDone generating 480 due to media server failure');
this.res.send(480, { this.res.send(480, {
headers: { headers: {
@@ -55,7 +43,6 @@ class InboundCallSession extends CallSession {
}); });
} }
else { else {
this.rootSpan.setAttributes({'call.termination': 'tasks completed without answering call'});
this.logger.info('InboundCallSession:_onTasksDone auto-generating non-success response to invite'); this.logger.info('InboundCallSession:_onTasksDone auto-generating non-success response to invite');
this.res.send(603); this.res.send(603);
} }
@@ -69,13 +56,8 @@ class InboundCallSession extends CallSession {
_callerHungup() { _callerHungup() {
assert(this.dlg.connectTime); assert(this.dlg.connectTime);
const duration = moment().diff(this.dlg.connectTime, 'seconds'); const duration = moment().diff(this.dlg.connectTime, 'seconds');
this.rootSpan.setAttributes({'call.termination': 'hangup by caller'}); this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
this.callInfo.callTerminationBy = 'caller'; this.logger.debug('InboundCallSession: caller hung up');
this.emit('callStatusChange', {
callStatus: CallStatus.Completed,
duration
});
this.logger.info('InboundCallSession: caller hung up');
this._callReleased(); this._callReleased();
this.req.removeAllListeners('cancel'); this.req.removeAllListeners('cancel');
} }

View File

@@ -8,7 +8,7 @@ const moment = require('moment');
* @extends CallSession * @extends CallSession
*/ */
class RestCallSession extends CallSession { class RestCallSession extends CallSession {
constructor({logger, application, srf, req, ep, tasks, callInfo, accountInfo, rootSpan}) { constructor({logger, application, srf, req, ep, tasks, callInfo, accountInfo}) {
super({ super({
logger, logger,
application, application,
@@ -16,18 +16,13 @@ class RestCallSession extends CallSession {
callSid: callInfo.callSid, callSid: callInfo.callSid,
tasks, tasks,
callInfo, callInfo,
accountInfo, accountInfo
rootSpan
}); });
this.req = req; this.req = req;
this.ep = ep; this.ep = ep;
this.on('callStatusChange', this._notifyCallStatusChange.bind(this)); this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
this._notifyCallStatusChange({ this._notifyCallStatusChange({callStatus: CallStatus.Trying, sipStatus: 100});
callStatus: CallStatus.Trying,
sipStatus: 100,
sipReason: 'Trying'
});
} }
/** /**
@@ -44,7 +39,6 @@ class RestCallSession extends CallSession {
* This is invoked when the called party hangs up, in order to calculate the call duration. * This is invoked when the called party hangs up, in order to calculate the call duration.
*/ */
_callerHungup() { _callerHungup() {
this.callInfo.callTerminationBy = 'caller';
const duration = moment().diff(this.dlg.connectTime, 'seconds'); const duration = moment().diff(this.dlg.connectTime, 'seconds');
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration}); this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
this.logger.debug('RestCallSession: called party hung up'); this.logger.debug('RestCallSession: called party hung up');

View File

@@ -1,59 +0,0 @@
const InboundCallSession = require('./inbound-call-session');
const {createSipRecPayload} = require('../utils/siprec-utils');
const {CallStatus} = require('../utils/constants');
/**
* @classdesc Subclass of InboundCallSession. This represents a CallSession that is
* established for an inbound SIPREC call.
* @extends InboundCallSession
*/
class SipRecCallSession extends InboundCallSession {
constructor(req, res) {
super(req, res);
const {sdp1, sdp2, metadata} = req.locals.siprec;
this.sdp1 = sdp1;
this.sdp2 = sdp2;
this.metadata = metadata;
}
async answerSipRecCall() {
try {
this.ms = this.getMS();
let remoteSdp = this.sdp1.replace(/sendonly/, 'sendrecv');
this.ep = await this.ms.createEndpoint({remoteSdp});
//this.logger.debug({remoteSdp, localSdp: this.ep.local.sdp}, 'SipRecCallSession - allocated first endpoint');
remoteSdp = this.sdp2.replace(/sendonly/, 'sendrecv');
this.ep2 = await this.ms.createEndpoint({remoteSdp});
//this.logger.debug({remoteSdp, localSdp: this.ep2.local.sdp}, 'SipRecCallSession - allocated second endpoint');
await this.ep.bridge(this.ep2);
const combinedSdp = await createSipRecPayload(this.ep.local.sdp, this.ep2.local.sdp, this.logger);
/*
this.logger.debug({
combinedSdp
}, 'SipRecCallSession:_answerSipRecCall - created SIPREC payload');
*/
this.dlg = await this.srf.createUAS(this.req, this.res, {
headers: {
'Content-Type': 'application/sdp',
'X-Trace-ID': this.req.locals.traceId,
'X-Call-Sid': this.req.locals.callSid,
...(this.applicationSid && {'X-Application-Sid': this.applicationSid})
},
localSdp: combinedSdp
});
this.dlg.on('destroy', this._callerHungup.bind(this));
this.wrapDialog(this.dlg);
this.dlg.callSid = this.callSid;
this.emit('callStatusChange', {sipStatus: 200, sipReason: 'OK', callStatus: CallStatus.InProgress});
this.dlg.on('modify', this._onReinvite.bind(this));
this.dlg.on('refer', this._onRefer.bind(this));
} catch (err) {
this.logger.error({err}, 'SipRecCallSession:_answerSipRecCall error:');
if (this.res && !this.res.finalResponseSent) this.res.send(500);
this._callReleased();
}
}
}
module.exports = SipRecCallSession;

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

@@ -72,7 +72,7 @@ class Conference extends Task {
get shouldRecord() { return this.record.path; } get shouldRecord() { return this.record.path; }
get isRecording() { return this.recordingInProgress; } get isRecording() { return this.recordingInProgress; }
async exec(cs, {ep}) { async exec(cs, ep) {
await super.exec(cs); await super.exec(cs);
this.ep = ep; this.ep = ep;
const dlg = cs.dlg; const dlg = cs.dlg;
@@ -108,10 +108,6 @@ class Conference extends Task {
async kill(cs) { async kill(cs) {
super.kill(cs); super.kill(cs);
this.logger.info(`Conference:kill ${this.confName}`); this.logger.info(`Conference:kill ${this.confName}`);
if (this._playSession) {
this._playSession.kill();
this._playSession = null;
}
this.emitter.emit('kill'); this.emitter.emit('kill');
await this._doFinalMemberCheck(cs); await this._doFinalMemberCheck(cs);
if (this.ep && this.ep.connected) this.ep.conn.removeAllListeners('esl::event::CUSTOM::*') ; if (this.ep && this.ep.connected) this.ep.conn.removeAllListeners('esl::event::CUSTOM::*') ;
@@ -431,19 +427,13 @@ class Conference extends Task {
.catch((err) => this.logger.info({err}, 'Error deafing or undeafing participant')); .catch((err) => this.logger.info({err}, 'Error deafing or undeafing participant'));
} }
if (wait_hook) {
if (this.wait_hook)
delete this.wait_hook.url;
this.wait_hook = {url: wait_hook};
}
if (hookOnly && this._playSession) { if (hookOnly && this._playSession) {
this._playSession.kill(); this._playSession.kill();
this._playSession = null; this._playSession = null;
} }
if (this.wait_hook?.url && this.conf_hold_status === 'hold') { if (wait_hook && this.conf_hold_status === 'hold') {
const {dlg} = cs; const {dlg} = cs;
this._doWaitHookWhileOnHold(cs, dlg, this.wait_hook); this._doWaitHookWhileOnHold(cs, dlg, wait_hook);
} }
else if (this.conf_hold_status !== 'hold' && this._playSession) { else if (this.conf_hold_status !== 'hold' && this._playSession) {
this._playSession.kill(); this._playSession.kill();
@@ -454,9 +444,7 @@ class Conference extends Task {
async _doWaitHookWhileOnHold(cs, dlg, wait_hook) { async _doWaitHookWhileOnHold(cs, dlg, wait_hook) {
do { do {
try { try {
let tasks = []; const tasks = await this._playHook(cs, dlg, wait_hook);
if (wait_hook.url)
tasks = await this._playHook(cs, dlg, wait_hook.url);
if (0 === tasks.length) break; if (0 === tasks.length) break;
} catch (err) { } catch (err) {
if (!this.killed) { if (!this.killed) {
@@ -465,7 +453,7 @@ class Conference extends Task {
this._playSession = null; this._playSession = null;
break; break;
} }
} while (!this.killed && this.conf_hold_status === 'hold'); } while (!this.killed && this.conf_hold_status !== 'hold');
} }
/** /**
@@ -541,9 +529,7 @@ class Conference extends Task {
async _playHook(cs, dlg, hook, allowed = [TaskName.Play, TaskName.Say, TaskName.Pause]) { async _playHook(cs, dlg, hook, allowed = [TaskName.Play, TaskName.Say, TaskName.Pause]) {
assert(!this._playSession); assert(!this._playSession);
const b3 = this.getTracingPropagation(); const json = await cs.application.requestor.request('verb:hook', hook, cs.callInfo);
const httpHeaders = b3 && {b3};
const json = await cs.application.requestor.request('verb:hook', hook, cs.callInfo, httpHeaders);
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata)); const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
const allowedTasks = tasks.filter((t) => allowed.includes(t.name)); const allowedTasks = tasks.filter((t) => allowed.includes(t.name));
@@ -553,9 +539,6 @@ class Conference extends Task {
} }
this.logger.debug(`Conference:_playHook: executing ${tasks.length} tasks`); this.logger.debug(`Conference:_playHook: executing ${tasks.length} tasks`);
/* we might have been killed while off fetching waitHook */
if (this.killed) return [];
if (tasks.length > 0) { if (tasks.length > 0) {
this._playSession = new ConfirmCallSession({ this._playSession = new ConfirmCallSession({
logger: this.logger, logger: this.logger,
@@ -566,8 +549,7 @@ class Conference extends Task {
accountInfo: cs.accountInfo, accountInfo: cs.accountInfo,
memberId: this.memberId, memberId: this.memberId,
confName: this.confName, confName: this.confName,
tasks, tasks
rootSpan: cs.rootSpan
}); });
await this._playSession.exec(); await this._playSession.exec();
this._playSession = null; this._playSession = null;
@@ -583,10 +565,6 @@ class Conference extends Task {
*/ */
_kicked(cs, dlg) { _kicked(cs, dlg) {
this.logger.info(`Conference:kicked - I was dropped from conference ${this.confName}, task is complete`); this.logger.info(`Conference:kicked - I was dropped from conference ${this.confName}, task is complete`);
if (this._playSession) {
this._playSession.kill();
this._playSession = null;
}
this.replaceEndpointAndEnd(cs); this.replaceEndpointAndEnd(cs);
} }
@@ -604,14 +582,11 @@ class Conference extends Task {
_notifyConferenceEvent(cs, eventName, params = {}) { _notifyConferenceEvent(cs, eventName, params = {}) {
if (this.statusEvents.includes(eventName)) { if (this.statusEvents.includes(eventName)) {
const b3 = this.getTracingPropagation();
const httpHeaders = b3 && {b3};
params.event = eventName; params.event = eventName;
params.duration = (Date.now() - this.conferenceStartTime.getTime()) / 1000; params.duration = (Date.now() - this.conferenceStartTime.getTime()) / 1000;
if (!params.time) params.time = (new Date()).toISOString(); if (!params.time) params.time = (new Date()).toISOString();
if (!params.members && typeof this.participantCount === 'number') params.members = this.participantCount; if (!params.members && typeof this.participantCount === 'number') params.members = this.participantCount;
cs.application.requestor cs.application.requestor.request('verb:hook', this.statusHook, Object.assign(params, this.statusParams))
.request('verb:hook', this.statusHook, Object.assign(params, this.statusParams, httpHeaders))
.catch((err) => this.logger.info(err, 'Conference:notifyConferenceEvent - error')); .catch((err) => this.logger.info(err, 'Conference:notifyConferenceEvent - error'));
} }
} }

View File

@@ -1,168 +0,0 @@
const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants');
class TaskConfig extends Task {
constructor(logger, opts) {
super(logger, opts);
[
'synthesizer',
'recognizer',
'bargeIn',
'record'
].forEach((k) => this[k] = this.data[k] || {});
if ('notifyEvents' in this.data) {
this.notifyEvents = !!this.data.notifyEvents;
}
if (this.bargeIn.enable) {
this.gatherOpts = {
verb: 'gather',
timeout: 0,
bargein: true,
input: ['speech']
};
[
'finishOnKey', 'input', 'numDigits', 'minDigits', 'maxDigits',
'interDigitTimeout', 'bargein', 'dtmfBargein', 'minBargeinWordCount', 'actionHook'
].forEach((k) => {
if (this.bargeIn[k]) this.gatherOpts[k] = this.bargeIn[k];
});
}
if (this.bargeIn.sticky) this.autoEnable = true;
this.preconditions = (this.bargeIn.enable || this.record?.action || this.data.amd) ?
TaskPreconditions.Endpoint :
TaskPreconditions.None;
}
get name() { return TaskName.Config; }
get hasSynthesizer() { return Object.keys(this.synthesizer).length; }
get hasRecognizer() { return Object.keys(this.recognizer).length; }
get summary() {
const phrase = [];
if (this.bargeIn.enable) phrase.push('enable barge-in');
if (this.hasSynthesizer) {
const {vendor:v, language:l, voice} = this.synthesizer;
const s = `{${v},${l},${voice}}`;
phrase.push(`set synthesizer${s}`);
}
if (this.hasRecognizer) {
const {vendor:v, language:l} = this.recognizer;
const s = `{${v},${l}}`;
phrase.push(`set recognizer${s}`);
}
if (this.data.amd) phrase.push('enable amd');
if (this.notifyEvents) phrase.push(`event notification ${this.notifyEvents ? 'on' : 'off'}`);
return `${this.name}{${phrase.join(',')}`;
}
async exec(cs, {ep} = {}) {
await super.exec(cs);
if (this.notifyEvents) {
this.logger.debug(`turning event notification ${this.notifyEvents ? 'on' : 'off'}`);
cs.notifyEvents = !!this.data.notifEvents;
}
if (this.data.amd) {
this.startAmd = cs.startAmd;
this.stopAmd = cs.stopAmd;
this.on('amd', this._onAmdEvent.bind(this, cs));
try {
this.ep = ep;
this.startAmd(cs, ep, this, this.data.amd);
} catch (err) {
this.logger.info({err}, 'Config:exec - Error calling startAmd');
}
}
if (this.hasSynthesizer) {
cs.speechSynthesisVendor = this.synthesizer.vendor !== 'default'
? this.synthesizer.vendor
: cs.speechSynthesisVendor;
cs.speechSynthesisLanguage = this.synthesizer.language !== 'default'
? this.synthesizer.language
: cs.speechSynthesisLanguage;
cs.speechSynthesisVoice = this.synthesizer.voice !== 'default'
? this.synthesizer.voice
: cs.speechSynthesisVoice;
this.logger.info({synthesizer: this.synthesizer}, 'Config: updated synthesizer');
}
if (this.hasRecognizer) {
cs.speechRecognizerVendor = this.recognizer.vendor !== 'default'
? this.recognizer.vendor
: cs.speechRecognizerVendor;
cs.speechRecognizerLanguage = this.recognizer.language !== 'default'
? this.recognizer.language
: cs.speechRecognizerLanguage;
cs.isContinuousAsr = typeof this.recognizer.asrTimeout === 'number' ? true : false;
if (cs.isContinuousAsr) {
cs.asrTimeout = this.recognizer.asrTimeout;
cs.asrDtmfTerminationDigit = this.recognizer.asrDtmfTerminationDigit;
}
if (Array.isArray(this.recognizer.hints)) {
const obj = {hints: this.recognizer.hints};
if (typeof this.recognizer.hintsBoost === 'number') {
obj.hintsBoost = this.recognizer.hintsBoost;
}
cs.globalSttHints = obj;
}
if (Array.isArray(this.recognizer.altLanguages)) {
this.logger.info({altLanguages: this.recognizer.altLanguages}, 'Config: updated altLanguages');
cs.altLanguages = this.recognizer.altLanguages;
}
if ('punctuation' in this.recognizer) {
cs.globalSttPunctuation = this.recognizer.punctuation;
}
this.logger.info({
recognizer: this.recognizer,
isContinuousAsr: cs.isContinuousAsr
}, 'Config: updated recognizer');
}
if ('enable' in this.bargeIn) {
if (this.bargeIn.enable === true && this.gatherOpts) {
this.gatherOpts.recognizer = this.hasRecognizer ?
this.recognizer :
{
vendor: cs.speechRecognizerVendor,
language: cs.speechRecognizerLanguage
};
this.logger.info({opts: this.gatherOpts}, 'Config: enabling bargeIn');
cs.enableBotMode(this.gatherOpts, this.autoEnable);
}
else if (this.bargeIn.enable === false) {
this.logger.info('Config: disabling bargeIn');
cs.disableBotMode();
}
}
if (this.record.action) {
try {
await cs.notifyRecordOptions(this.record);
} catch (err) {
this.logger.info({err}, 'Config: error starting recording');
}
}
}
async kill(cs) {
super.kill(cs);
if (this.ep && this.stopAmd) this.stopAmd(this.ep, this);
}
_onAmdEvent(cs, evt) {
this.logger.info({evt}, 'Config:_onAmdEvent');
const {actionHook} = this.data.amd;
this.performHook(cs, actionHook, evt)
.catch((err) => {
this.logger.error({err}, 'Config:_onAmdEvent - error calling actionHook');
});
}
}
module.exports = TaskConfig;

View File

@@ -23,7 +23,7 @@ class TaskDequeue extends Task {
get name() { return TaskName.Dequeue; } get name() { return TaskName.Dequeue; }
async exec(cs, {ep}) { async exec(cs, ep) {
await super.exec(cs); await super.exec(cs);
this.ep = ep; this.ep = ep;
this.queueName = `queue:${cs.accountSid}:${this.queueName}`; this.queueName = `queue:${cs.accountSid}:${this.queueName}`;

View File

@@ -14,7 +14,6 @@ const sessionTracker = require('../session/session-tracker');
const DtmfCollector = require('../utils/dtmf-collector'); const DtmfCollector = require('../utils/dtmf-collector');
const dbUtils = require('../utils/db-utils'); const dbUtils = require('../utils/db-utils');
const debug = require('debug')('jambonz:feature-server'); const debug = require('debug')('jambonz:feature-server');
const {parseUri} = require('drachtio-srf');
function parseDtmfOptions(logger, dtmfCapture) { function parseDtmfOptions(logger, dtmfCapture) {
let parentDtmfCollector, childDtmfCollector; let parentDtmfCollector, childDtmfCollector;
@@ -92,7 +91,6 @@ class TaskDial extends Task {
this.timeLimit = this.data.timeLimit; this.timeLimit = this.data.timeLimit;
this.confirmHook = this.data.confirmHook; this.confirmHook = this.data.confirmHook;
this.confirmMethod = this.data.confirmMethod; this.confirmMethod = this.data.confirmMethod;
this.referHook = this.data.referHook;
this.dtmfHook = this.data.dtmfHook; this.dtmfHook = this.data.dtmfHook;
this.proxy = this.data.proxy; this.proxy = this.data.proxy;
@@ -134,38 +132,12 @@ class TaskDial extends Task {
get name() { return TaskName.Dial; } get name() { return TaskName.Dial; }
get canReleaseMedia() { get canReleaseMedia() {
return !process.env.ANCHOR_MEDIA_ALWAYS && return !process.env.ANCHOR_MEDIA_ALWAYS && !this.listenTask && !this.transcribeTask;
!this.listenTask &&
!this.transcribeTask &&
!this.startAmd;
}
get summary() {
if (this.target.length === 1) {
const target = this.target[0];
switch (target.type) {
case 'phone':
case 'teams':
return `${this.name}{type=${target.type},number=${target.number}}`;
case 'user':
return `${this.name}{type=${target.type},name=${target.name}}`;
case 'sip':
return `${this.name}{type=${target.type},sipUri=${target.sipUri}}`;
default:
return `${this.name}`;
}
}
else return `${this.name}{${this.target.length} targets}`;
} }
async exec(cs) { async exec(cs) {
await super.exec(cs); await super.exec(cs);
try { try {
if (this.data.amd) {
this.startAmd = cs.startAmd;
this.stopAmd = cs.stopAmd;
this.on('amd', this._onAmdEvent.bind(this, cs));
}
if (cs.direction === CallDirection.Inbound) { if (cs.direction === CallDirection.Inbound) {
await this._initializeInbound(cs); await this._initializeInbound(cs);
} }
@@ -189,11 +161,6 @@ class TaskDial extends Task {
async kill(cs, reason) { async kill(cs, reason) {
super.kill(cs); super.kill(cs);
try {
if (this.ep && this.ep.amd) this.stopAmd(this.ep, this);
} catch (err) {
this.logger.error({err}, 'DialTask:kill - error stopping answering machine detectin');
}
if (this.dialMusic && this.epOther) { if (this.dialMusic && this.epOther) {
this.epOther.api('uuid_break', this.epOther.uuid) this.epOther.api('uuid_break', this.epOther.uuid)
.catch((err) => this.logger.info(err, 'Error killing dialMusic')); .catch((err) => this.logger.info(err, 'Error killing dialMusic'));
@@ -216,14 +183,8 @@ class TaskDial extends Task {
this.sd = null; this.sd = null;
} }
if (this.callSid) sessionTracker.remove(this.callSid); if (this.callSid) sessionTracker.remove(this.callSid);
if (this.listenTask) { if (this.listenTask) await this.listenTask.kill(cs);
await this.listenTask.kill(cs); if (this.transcribeTask) await this.transcribeTask.kill(cs);
this.listenTask = null;
}
if (this.transcribeTask) {
await this.transcribeTask.kill(cs);
this.transcribeTask = null;
}
this.notifyTaskDone(); this.notifyTaskDone();
} }
@@ -245,11 +206,7 @@ class TaskDial extends Task {
this.logger.debug('Dial:whisper executing tasks'); this.logger.debug('Dial:whisper executing tasks');
while (tasks.length && !cs.callGone) { while (tasks.length && !cs.callGone) {
const task = tasks.shift(); const task = tasks.shift();
const {span, ctx} = this.startChildSpan(`whisper:${task.summary}`); await task.exec(cs, callSid === this.callSid ? this.ep : this.epOther);
task.span = span;
task.ctx = ctx;
await task.exec(cs, callSid === this.callSid ? {ep: this.ep} : {ep: this.epOther});
span.end();
} }
this.logger.debug('Dial:whisper tasks complete'); this.logger.debug('Dial:whisper tasks complete');
if (!cs.callGone && this.epOther) { if (!cs.callGone && this.epOther) {
@@ -283,43 +240,6 @@ class TaskDial extends Task {
} }
} }
async handleRefer(cs, req, res, callInfo = cs.callInfo) {
if (this.referHook) {
try {
const isChild = !!callInfo.parentCallSid;
const referring_call_sid = isChild ? callInfo.callSid : cs.callSid;
const referred_call_sid = isChild ? callInfo.parentCallSid : this.sd.callSid;
const b3 = this.getTracingPropagation();
const httpHeaders = b3 && {b3};
const to = parseUri(req.getParsedHeader('Refer-To').uri);
const by = parseUri(req.getParsedHeader('Referred-By').uri);
this.logger.info({to}, 'refer to parsed');
await cs.requestor.request('verb:hook', this.referHook, {
...callInfo,
refer_details: {
sip_refer_to: req.get('Refer-To'),
sip_referred_by: req.get('Referred-By'),
sip_user_agent: req.get('User-Agent'),
refer_to_user: to.user,
referred_by_user: by.user,
referring_call_sid,
referred_call_sid
}
}, httpHeaders);
res.send(202);
this.logger.info('DialTask:handleRefer - sent 202 Accepted');
} catch (err) {
res.send(err.statusCode || 501);
}
}
else {
this.logger.info('DialTask:handleRefer - got REFER but no referHook, responding 501');
res.send(501);
}
}
_removeHandlers(sd) { _removeHandlers(sd) {
sd.removeAllListeners('accept'); sd.removeAllListeners('accept');
sd.removeAllListeners('decline'); sd.removeAllListeners('decline');
@@ -367,17 +287,16 @@ class TaskDial extends Task {
const key = arr[1]; const key = arr[1];
const match = dtmfDetector.keyPress(key); const match = dtmfDetector.keyPress(key);
if (match) { if (match) {
const b3 = this.getTracingPropagation();
const httpHeaders = b3 && {b3};
this.logger.info({callSid}, `Dial:_onInfo triggered dtmf match: ${match}`); this.logger.info({callSid}, `Dial:_onInfo triggered dtmf match: ${match}`);
requestor.request('verb:hook', this.dtmfHook, {dtmf: match, ...callInfo.toJSON()}, httpHeaders) requestor.request('verb:hook', this.dtmfHook, {dtmf: match, ...callInfo.toJSON()})
.catch((err) => this.logger.info(err, 'Dial:_onDtmf - error')); .catch((err) => this.logger.info(err, 'Dial:_onDtmf - error'));
} }
} }
async _initializeInbound(cs) { async _initializeInbound(cs) {
const {ep} = await cs._evalEndpointPrecondition(this); const ep = await cs._evalEndpointPrecondition(this);
this.epOther = ep; this.epOther = ep;
debug(`Dial:__initializeInbound allocated ep for incoming call: ${ep.uuid}`);
/* send outbound legs back to the same SBC (to support static IP feature) */ /* 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}`; if (!this.proxy) this.proxy = `${cs.req.source_address}:${cs.req.source_port}`;
@@ -422,18 +341,12 @@ class TaskDial extends Task {
this.logger.info(`Dial:_attemptCall: ring no answer timer ${this.timeout}s exceeded`); this.logger.info(`Dial:_attemptCall: ring no answer timer ${this.timeout}s exceeded`);
this.timerRing = null; this.timerRing = null;
this._killOutdials(); this._killOutdials();
this.result = {
dialCallStatus: CallStatus.NoAnswer,
dialSipStatus: 487
};
this.kill(cs);
}, this.timeout * 1000); }, this.timeout * 1000);
this.span.setAttributes({'dial.target': JSON.stringify(this.target)});
this.target.forEach(async(t) => { this.target.forEach(async(t) => {
try { try {
t.confirmHook = t.confirmHook || this.confirmHook; t.url = t.url || this.confirmUrl;
//t.method = t.method || this.confirmMethod || 'POST'; t.method = t.method || this.confirmMethod || 'POST';
if (t.type === 'teams') t.teamsInfo = teamsInfo; if (t.type === 'teams') t.teamsInfo = teamsInfo;
if (t.type === 'user' && !t.name.includes('@') && !fqdn) { if (t.type === 'user' && !t.name.includes('@') && !fqdn) {
const user = t.name; const user = t.name;
@@ -466,14 +379,11 @@ class TaskDial extends Task {
target: t, target: t,
opts, opts,
callInfo: cs.callInfo, callInfo: cs.callInfo,
accountInfo: cs.accountInfo, accountInfo: cs.accountInfo
rootSpan: cs.rootSpan,
startSpan: this.startSpan.bind(this)
}); });
this.dials.set(sd.callSid, sd); this.dials.set(sd.callSid, sd);
sd sd
.on('refer', (callInfo, req, res) => this.handleRefer(cs, req, res, callInfo))
.on('callCreateFail', () => { .on('callCreateFail', () => {
clearTimeout(this.timerRing); clearTimeout(this.timerRing);
this.dials.delete(sd.callSid); this.dials.delete(sd.callSid);
@@ -542,9 +452,6 @@ class TaskDial extends Task {
} catch (err) { } catch (err) {
this.logger.error(err, 'Error in dial einvite from B leg'); this.logger.error(err, 'Error in dial einvite from B leg');
} }
})
.on('refer', (callInfo, req, res) => {
}) })
.once('adulting', () => { .once('adulting', () => {
/* child call just adulted and got its own session */ /* child call just adulted and got its own session */
@@ -589,7 +496,6 @@ class TaskDial extends Task {
* - save the dialog and endpoint * - save the dialog and endpoint
* - clock the start time of the call, * - clock the start time of the call,
* - start a max call length timer (optionally) * - start a max call length timer (optionally)
* - start answering machine detection (optionally)
* - launch any nested tasks * - launch any nested tasks
* - and establish a handler to clean up if the called party hangs up * - and establish a handler to clean up if the called party hangs up
*/ */
@@ -630,18 +536,11 @@ class TaskDial extends Task {
if (this.parentDtmfCollector) this._installDtmfDetection(cs, cs.dlg); if (this.parentDtmfCollector) this._installDtmfDetection(cs, cs.dlg);
if (this.childDtmfCollector) this._installDtmfDetection(cs, this.dlg); if (this.childDtmfCollector) this._installDtmfDetection(cs, this.dlg);
if (this.transcribeTask) this.transcribeTask.exec(cs, {ep2: this.epOther, ep:this.ep}); if (this.transcribeTask) this.transcribeTask.exec(cs, this.ep);
if (this.listenTask) this.listenTask.exec(cs, {ep: this.epOther}); if (this.listenTask) this.listenTask.exec(cs, this.ep);
if (this.startAmd) {
try {
this.startAmd(cs, this.ep, this, this.data.amd);
} catch (err) {
this.logger.info({err}, 'Dial:_selectSingleDial - Error calling startAmd');
}
}
/* if we can release the media back to the SBC, do so now */ /* if we can release the media back to the SBC, do so now */
if (this.canReleaseMedia) setTimeout(this._releaseMedia.bind(this, cs, sd), 200); if (this.canReleaseMedia) this._releaseMedia(cs, sd);
} }
_bridgeEarlyMedia(sd) { _bridgeEarlyMedia(sd) {
@@ -685,15 +584,6 @@ class TaskDial extends Task {
this.logger.info({sdp}, 'Dial:handleReinviteAfterMediaReleased - sent reinvite to B leg'); this.logger.info({sdp}, 'Dial:handleReinviteAfterMediaReleased - sent reinvite to B leg');
res.send(200, {body: sdp}); res.send(200, {body: sdp});
} }
_onAmdEvent(cs, evt) {
this.logger.info({evt}, 'Dial:_onAmdEvent');
const {actionHook} = this.data.amd;
this.performHook(cs, actionHook, evt)
.catch((err) => {
this.logger.error({err}, 'Dial:_onAmdEvent - error calling actionHook');
});
}
} }
module.exports = TaskDial; module.exports = TaskDial;

View File

@@ -64,7 +64,7 @@ class Dialogflow extends Task {
get name() { return TaskName.Dialogflow; } get name() { return TaskName.Dialogflow; }
async exec(cs, {ep}) { async exec(cs, ep) {
await super.exec(cs); await super.exec(cs);
try { try {
@@ -295,9 +295,9 @@ class Dialogflow extends Task {
} }
// if a final transcription, start a typing sound // if a final transcription, start a typing sound
if (this.thinkingMusic && !transcription.isEmpty && transcription.isFinal && if (this.thinkingSound > 0 && !transcription.isEmpty && transcription.isFinal &&
transcription.confidence > 0.8) { transcription.confidence > 0.8) {
ep.play(this.data.thinkingMusic).catch((err) => this.logger.info(err, 'Error playing typing sound')); ep.play(this.data.thinkingSound).catch((err) => this.logger.info(err, 'Error playing typing sound'));
} }
// interrupt playback on speaking if bargein = true // interrupt playback on speaking if bargein = true
@@ -405,8 +405,8 @@ class Dialogflow extends Task {
this.dtmfEntry = dtmfEntry; this.dtmfEntry = dtmfEntry;
this.digitBuffer = null; this.digitBuffer = null;
// if a final transcription, start a typing sound // if a final transcription, start a typing sound
if (this.thinkingMusic) { if (this.thinkingSound > 0) {
ep.play(this.thinkingMusic).catch((err) => this.logger.info(err, 'Error playing typing sound')); ep.play(this.thinkingSound).catch((err) => this.logger.info(err, 'Error playing typing sound'));
} }
// kill the current dialogflow, which will result in us getting an immediate intent // kill the current dialogflow, which will result in us getting an immediate intent
@@ -453,10 +453,7 @@ class Dialogflow extends Task {
} }
async _performHook(cs, hook, results = {}) { async _performHook(cs, hook, results = {}) {
const b3 = this.getTracingPropagation(); const json = await this.cs.requestor.request('verb:hook', hook, {...results, ...cs.callInfo.toJSON()});
const httpHeaders = b3 && {b3};
const json = await this.cs.requestor.request('verb:hook', hook,
{...results, ...cs.callInfo.toJSON()}, httpHeaders);
if (json && Array.isArray(json)) { if (json && Array.isArray(json)) {
const makeTask = require('../make_task'); const makeTask = require('../make_task');
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata)); const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));

View File

@@ -12,7 +12,7 @@ class TaskDtmf extends Task {
get name() { return TaskName.Dtmf; } get name() { return TaskName.Dtmf; }
async exec(cs, {ep}) { async exec(cs, ep) {
await super.exec(cs); await super.exec(cs);
this.ep = ep; this.ep = ep;
try { try {

View File

@@ -37,7 +37,7 @@ class TaskEnqueue extends Task {
get name() { return TaskName.Enqueue; } get name() { return TaskName.Enqueue; }
async exec(cs, {ep}) { async exec(cs, ep) {
await super.exec(cs); await super.exec(cs);
const dlg = cs.dlg; const dlg = cs.dlg;
this.queueName = `queue:${cs.accountSid}:${this.queueName}`; this.queueName = `queue:${cs.accountSid}:${this.queueName}`;
@@ -302,8 +302,6 @@ class TaskEnqueue extends Task {
async _playHook(cs, dlg, hook, allowed = [TaskName.Play, TaskName.Say, TaskName.Pause, TaskName.Leave]) { async _playHook(cs, dlg, hook, allowed = [TaskName.Play, TaskName.Say, TaskName.Pause, TaskName.Leave]) {
const {lengthOfList, getListPosition} = cs.srf.locals.dbHelpers; const {lengthOfList, getListPosition} = cs.srf.locals.dbHelpers;
const b3 = this.getTracingPropagation();
const httpHeaders = b3 && {b3};
assert(!this._playSession); assert(!this._playSession);
if (this.killed) return []; if (this.killed) return [];
@@ -319,7 +317,7 @@ class TaskEnqueue extends Task {
} catch (err) { } catch (err) {
this.logger.error({err}, `TaskEnqueue:_playHook error retrieving list info for queue ${this.queueName}`); this.logger.error({err}, `TaskEnqueue:_playHook error retrieving list info for queue ${this.queueName}`);
} }
const json = await cs.application.requestor.request('verb:hook', hook, params, httpHeaders); const json = await cs.application.requestor.request('verb:hook', hook, params);
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata)); const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
const allowedTasks = tasks.filter((t) => allowed.includes(t.name)); const allowedTasks = tasks.filter((t) => allowed.includes(t.name));
@@ -349,8 +347,7 @@ class TaskEnqueue extends Task {
ep: cs.ep, ep: cs.ep,
callInfo: cs.callInfo, callInfo: cs.callInfo,
accountInfo: cs.accountInfo, accountInfo: cs.accountInfo,
tasks: tasksToRun, tasks: tasksToRun
rootSpan: cs.rootSpan
}); });
await this._playSession.exec(); await this._playSession.exec();
this._playSession = null; this._playSession = null;

View File

@@ -3,41 +3,19 @@ const {
TaskName, TaskName,
TaskPreconditions, TaskPreconditions,
GoogleTranscriptionEvents, GoogleTranscriptionEvents,
NuanceTranscriptionEvents,
AwsTranscriptionEvents, AwsTranscriptionEvents,
AzureTranscriptionEvents, AzureTranscriptionEvents
DeepgramTranscriptionEvents,
IbmTranscriptionEvents
} = require('../utils/constants'); } = require('../utils/constants');
const makeTask = require('./make_task'); const makeTask = require('./make_task');
const assert = require('assert'); const assert = require('assert');
const GATHER_STABILITY_THRESHOLD = Number(process.env.JAMBONZ_GATHER_STABILITY_THRESHOLD || 0.7);
const compileTranscripts = (logger, evt, arr) => {
if (!Array.isArray(arr) || arr.length === 0) return;
let t = '';
for (const a of arr) {
t += ` ${a.alternatives[0].transcript}`;
}
t += ` ${evt.alternatives[0].transcript}`;
evt.alternatives[0].transcript = t.trim();
};
class TaskGather extends Task { class TaskGather extends Task {
constructor(logger, opts, parentTask) { constructor(logger, opts, parentTask) {
super(logger, opts); super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint; this.preconditions = TaskPreconditions.Endpoint;
const {
setChannelVarsForStt,
normalizeTranscription,
removeSpeechListeners,
setSpeechCredentialsAtRuntime
} = require('../utils/transcription-utils')(logger);
this.setChannelVarsForStt = setChannelVarsForStt;
this.normalizeTranscription = normalizeTranscription;
this.removeSpeechListeners = removeSpeechListeners;
[ [
'finishOnKey', 'hints', 'input', 'numDigits', 'minDigits', 'maxDigits', 'finishOnKey', 'hints', 'input', 'numDigits', 'minDigits', 'maxDigits',
'interDigitTimeout', 'partialResultHook', 'bargein', 'dtmfBargein', 'interDigitTimeout', 'partialResultHook', 'bargein', 'dtmfBargein',
@@ -45,44 +23,48 @@ class TaskGather extends Task {
].forEach((k) => this[k] = this.data[k]); ].forEach((k) => this[k] = this.data[k]);
/* when collecting dtmf, bargein on dtmf is true unless explicitly set to false */ /* when collecting dtmf, bargein on dtmf is true unless explicitly set to false */
if (this.dtmfBargein !== false && this.input.includes('digits')) this.dtmfBargein = true; if (this.dtmfBargein !== false && this.input.includes('digits')) this.dtmfBargein = true;
/* timeout of zero means no timeout */ this.timeout = (this.timeout || 15) * 1000;
this.timeout = this.timeout === 0 ? 0 : (this.timeout || 15) * 1000; this.interim = this.partialResultCallback;
this.interim = !!this.partialResultHook || this.bargein;
this.listenDuringPrompt = this.data.listenDuringPrompt === false ? false : true;
this.minBargeinWordCount = this.data.minBargeinWordCount || 0;
if (this.data.recognizer) { if (this.data.recognizer) {
const recognizer = this.data.recognizer; const recognizer = this.data.recognizer;
this.vendor = recognizer.vendor; this.vendor = recognizer.vendor;
this.language = recognizer.language; this.language = recognizer.language;
this.hints = recognizer.hints || [];
this.altLanguages = recognizer.altLanguages || [];
/* let credentials be supplied in the recognizer object at runtime */ /* vad: if provided, we dont connect to recognizer until voice activity is detected */
this.sttCredentials = setSpeechCredentialsAtRuntime(recognizer); const {enable, voiceMs = 0, mode = -1} = recognizer.vad || {};
this.vad = {enable, voiceMs, mode};
/* continuous ASR (i.e. compile transcripts until a special timeout or dtmf key) */ /* aws options */
this.asrTimeout = typeof recognizer.asrTimeout === 'number' ? recognizer.asrTimeout * 1000 : 0; this.vocabularyName = recognizer.vocabularyName;
if (this.asrTimeout > 0) this.asrDtmfTerminationDigit = recognizer.asrDtmfTerminationDigit; this.vocabularyFilterName = recognizer.vocabularyFilterName;
this.isContinuousAsr = this.asrTimeout > 0; this.filterMethod = recognizer.filterMethod;
this.data.recognizer.hints = this.data.recognizer.hints || []; /* microsoft options */
this.data.recognizer.altLanguages = this.data.recognizer.altLanguages || []; this.outputFormat = recognizer.outputFormat || 'simple';
this.profanityOption = recognizer.profanityOption || 'raw';
this.requestSnr = recognizer.requestSnr || false;
this.initialSpeechTimeoutMs = recognizer.initialSpeechTimeoutMs || 0;
/* barge in configuration */
this.listenDuringPrompt = this.data.listenDuringPrompt === false ? false : true;
this.minBargeinWordCount = this.data.minBargeinWordCount || 1;
} }
else this.data.recognizer = {hints: [], altLanguages: []};
this.digitBuffer = ''; this.digitBuffer = '';
this._earlyMedia = this.data.earlyMedia === true; this._earlyMedia = this.data.earlyMedia === true;
if (this.say) { if (this.say) this.sayTask = makeTask(this.logger, {say: this.say}, this);
this.sayTask = makeTask(this.logger, {say: this.say}, this); if (this.play) this.playTask = makeTask(this.logger, {play: this.play}, this);
}
if (this.play) {
this.playTask = makeTask(this.logger, {play: this.play}, this);
}
if (!this.sayTask && !this.playTask) this.listenDuringPrompt = false;
/* buffer speech for continuous asr */ if (this.sayTask || this.playTask) {
this._bufferedTranscripts = []; // this is specially for barge in where we want to make a bargebale promt
// to a user without listening after the say task has finished
this.listenAfterSpeech = typeof this.data.listenAfterSpeech === 'boolean' ? this.data.listenAfterSpeech : true;
}
this.parentTask = parentTask; this.parentTask = parentTask;
} }
@@ -96,58 +78,14 @@ class TaskGather extends Task {
(this.playTask && this.playTask.earlyMedia); (this.playTask && this.playTask.earlyMedia);
} }
get summary() { async exec(cs, ep) {
let s = `${this.name}{`;
if (this.input.length === 2) s += 'inputs=[speech,digits],';
else if (this.input.includes('digits')) s += 'inputs=digits';
else s += 'inputs=speech,';
if (this.input.includes('speech')) {
s += `vendor=${this.vendor || 'default'},language=${this.language || 'default'}`;
}
if (this.sayTask) s += ',with nested say task';
if (this.playTask) s += ',with nested play task';
s += '}';
return s;
}
async exec(cs, {ep}) {
this.logger.debug('Gather:exec');
await super.exec(cs); await super.exec(cs);
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf); const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
const {getNuanceAccessToken, getIbmAccessToken} = cs.srf.locals.dbHelpers;
if (cs.hasGlobalSttHints) {
const {hints, hintsBoost} = cs.globalSttHints;
this.data.recognizer.hints = this.data.recognizer.hints.concat(hints);
if (!this.data.recognizer.hintsBoost && hintsBoost) this.data.recognizer.hintsBoost = hintsBoost;
this.logger.debug({hints: this.data.recognizer.hints, hintsBoost: this.data.recognizer.hintsBoost},
'Gather:exec - applying global sttHints');
}
if (cs.hasAltLanguages) {
this.data.recognizer.altLanguages = this.data.recognizer.altLanguages.concat(cs.altLanguages);
this.logger.debug({altLanguages: this.altLanguages},
'Gather:exec - applying altLanguages');
}
if (cs.hasGlobalSttPunctuation && !this.data.recognizer.punctuation) {
this.data.recognizer.punctuation = cs.globalSttPunctuation;
}
if (!this.isContinuousAsr && cs.isContinuousAsr) {
this.isContinuousAsr = true;
this.asrTimeout = cs.asrTimeout * 1000;
this.asrDtmfTerminationDigit = cs.asrDtmfTerminationDigit;
this.logger.debug({
asrTimeout: this.asrTimeout,
asrDtmfTerminationDigit: this.asrDtmfTerminationDigit
}, 'Gather:exec - enabling continuous ASR since it is turned on for the session');
}
this.ep = ep; this.ep = ep;
if ('default' === this.vendor || !this.vendor) this.vendor = cs.speechRecognizerVendor; if ('default' === this.vendor || !this.vendor) this.vendor = cs.speechRecognizerVendor;
if ('default' === this.language || !this.language) this.language = cs.speechRecognizerLanguage; if ('default' === this.language || !this.language) this.language = cs.speechRecognizerLanguage;
if (!this.data.recognizer.vendor) { this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt');
this.data.recognizer.vendor = this.vendor;
}
if (this.needsStt && !this.sttCredentials) this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt');
if (this.needsStt && !this.sttCredentials) { if (this.needsStt && !this.sttCredentials) {
const {writeAlerts, AlertType} = cs.srf.locals; const {writeAlerts, AlertType} = cs.srf.locals;
this.logger.info(`TaskGather:exec - ERROR stt using ${this.vendor} requested but creds not supplied`); this.logger.info(`TaskGather:exec - ERROR stt using ${this.vendor} requested but creds not supplied`);
@@ -160,89 +98,47 @@ class TaskGather extends Task {
throw new Error(`no speech-to-text service credentials for ${this.vendor} have been configured`); throw new Error(`no speech-to-text service credentials for ${this.vendor} have been configured`);
} }
this.logger.info({sttCredentials: this.sttCredentials}, 'Gather:exec - sttCredentials');
if (this.vendor === 'nuance' && this.sttCredentials.client_id) {
/* get nuance access token */
const {client_id, secret} = this.sttCredentials;
const {access_token, servedFromCache} = await getNuanceAccessToken(client_id, secret, 'asr tts');
this.logger.debug({client_id}, `Gather:exec - got nuance access token ${servedFromCache ? 'from cache' : ''}`);
this.sttCredentials = {...this.sttCredentials, access_token};
}
else if (this.vendor == 'ibm' && this.sttCredentials.stt_api_key) {
/* get ibm access token */
const {stt_api_key, stt_region} = this.sttCredentials;
const {access_token, servedFromCache} = await getIbmAccessToken(stt_api_key);
this.logger.debug({stt_api_key}, `Gather:exec - got ibm access token ${servedFromCache ? 'from cache' : ''}`);
this.sttCredentials = {...this.sttCredentials, access_token, stt_region};
}
const startListening = (cs, ep) => { const startListening = (cs, ep) => {
this._startTimer(); this._startTimer();
if (this.isContinuousAsr && 0 === this.timeout) this._startAsrTimer();
if (this.input.includes('speech') && !this.listenDuringPrompt) { if (this.input.includes('speech') && !this.listenDuringPrompt) {
this.logger.debug('Gather:exec - calling _initSpeech');
this._initSpeech(cs, ep) this._initSpeech(cs, ep)
.then(() => { .then(() => {
if (this.killed) {
this.logger.info('Gather:exec - task was quickly killed so do not transcribe');
return;
}
this._startTranscribing(ep); this._startTranscribing(ep);
return updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid); return updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid);
}) })
.catch((err) => { .catch(() => {});
this.logger.error({err}, 'error in initSpeech');
});
} }
}; };
try { try {
if (this.sayTask) { if (this.sayTask) {
const {span, ctx} = this.startChildSpan(`nested:${this.sayTask.summary}`); this.sayTask.exec(cs, ep); // kicked off, _not_ waiting for it to complete
this.sayTask.span = span;
this.sayTask.ctx = ctx;
this.sayTask.exec(cs, {ep}); // kicked off, _not_ waiting for it to complete
this.sayTask.on('playDone', (err) => { this.sayTask.on('playDone', (err) => {
span.end(); if (err) return this.logger.error({err}, 'Gather:exec Error playing tts');
if (err) this.logger.error({err}, 'Gather:exec Error playing tts'); this.logger.info('Gather: say task completed');
this.logger.debug('Gather: nested say task completed');
if (!this.killed) { if (!this.killed) {
startListening(cs, ep); if (this.listenAfterSpeech === true) {
if (this.input.includes('speech') && this.vendor === 'nuance' && this.listenDuringPrompt) { startListening(cs, ep);
this.logger.debug('Gather:exec - starting transcription timers after say completes'); } else {
ep.startTranscriptionTimers((err) => { this.notifyTaskDone();
if (err) this.logger.error({err}, 'Gather:exec - error starting transcription timers');
});
} }
} }
}); });
} }
else if (this.playTask) { else if (this.playTask) {
const {span, ctx} = this.startChildSpan(`nested:${this.playTask.summary}`); this.playTask.exec(cs, ep); // kicked off, _not_ waiting for it to complete
this.playTask.span = span;
this.playTask.ctx = ctx;
this.playTask.exec(cs, {ep}); // kicked off, _not_ waiting for it to complete
this.playTask.on('playDone', (err) => { this.playTask.on('playDone', (err) => {
span.end(); if (err) return this.logger.error({err}, 'Gather:exec Error playing url');
if (err) this.logger.error({err}, 'Gather:exec Error playing url');
this.logger.debug('Gather: nested play task completed');
if (!this.killed) { if (!this.killed) {
startListening(cs, ep); if (this.listenAfterSpeech === true) {
if (this.input.includes('speech') && this.vendor === 'nuance' && this.listenDuringPrompt) { startListening(cs, ep);
this.logger.debug('Gather:exec - starting transcription timers after play completes'); } else {
ep.startTranscriptionTimers((err) => { this.notifyTaskDone();
if (err) this.logger.error({err}, 'Gather:exec - error starting transcription timers');
});
} }
} }
}); });
} }
else { else startListening(cs, ep);
if (this.killed) {
this.logger.info('Gather:exec - task was immediately killed so do not transcribe');
return;
}
startListening(cs, ep);
}
if (this.input.includes('speech') && this.listenDuringPrompt) { if (this.input.includes('speech') && this.listenDuringPrompt) {
await this._initSpeech(cs, ep); await this._initSpeech(cs, ep);
@@ -251,7 +147,7 @@ class TaskGather extends Task {
.catch(() => {/*already logged error */}); .catch(() => {/*already logged error */});
} }
if (this.input.includes('digits') || this.dtmfBargein || this.asrDtmfTerminationDigit) { if (this.input.includes('digits') || this.dtmfBargein) {
ep.on('dtmf', this._onDtmf.bind(this, cs, ep)); ep.on('dtmf', this._onDtmf.bind(this, cs, ep));
} }
@@ -259,7 +155,11 @@ class TaskGather extends Task {
} catch (err) { } catch (err) {
this.logger.error(err, 'TaskGather:exec error'); this.logger.error(err, 'TaskGather:exec error');
} }
this.removeSpeechListeners(ep); ep.removeCustomEventListener(GoogleTranscriptionEvents.Transcription);
ep.removeCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance);
ep.removeCustomEventListener(AwsTranscriptionEvents.Transcription);
ep.removeCustomEventListener(AzureTranscriptionEvents.Transcription);
ep.removeCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected);
} }
kill(cs) { kill(cs) {
@@ -267,34 +167,19 @@ class TaskGather extends Task {
this._killAudio(cs); this._killAudio(cs);
this.ep.removeAllListeners('dtmf'); this.ep.removeAllListeners('dtmf');
clearTimeout(this.interDigitTimer); clearTimeout(this.interDigitTimer);
this.playTask?.span.end();
this.sayTask?.span.end();
this._resolve('killed'); this._resolve('killed');
} }
updateTaskInProgress(opts) {
if (!this.needsStt && opts.input.includes('speech')) {
this.logger.info('TaskGather:updateTaskInProgress - adding speech to a background gather');
return false; // this needs be handled by killing the background gather and starting a new one
}
const {timeout} = opts;
this.timeout = timeout;
this._startTimer();
}
_onDtmf(cs, ep, evt) { _onDtmf(cs, ep, evt) {
this.logger.debug(evt, 'TaskGather:_onDtmf'); this.logger.debug(evt, 'TaskGather:_onDtmf');
clearTimeout(this.interDigitTimer); clearTimeout(this.interDigitTimer);
let resolved = false; let resolved = false;
if (this.dtmfBargein) { if (this.dtmfBargein) this._killAudio(cs);
this._killAudio(cs);
this.emit('dtmf', evt);
}
if (evt.dtmf === this.finishOnKey && this.input.includes('digits')) { if (evt.dtmf === this.finishOnKey && this.input.includes('digits')) {
resolved = true; resolved = true;
this._resolve('dtmf-terminator-key'); this._resolve('dtmf-terminator-key');
} }
else if (this.input.includes('digits')) { else {
this.digitBuffer += evt.dtmf; this.digitBuffer += evt.dtmf;
const len = this.digitBuffer.length; const len = this.digitBuffer.length;
if (len === this.numDigits || len === this.maxDigits) { if (len === this.numDigits || len === this.maxDigits) {
@@ -302,13 +187,6 @@ class TaskGather extends Task {
this._resolve('dtmf-num-digits'); this._resolve('dtmf-num-digits');
} }
} }
else if (this.isContinuousAsr && evt.dtmf === this.asrDtmfTerminationDigit) {
this.logger.info(`continuousAsr triggered with dtmf ${this.asrDtmfTerminationDigit}`);
this._clearAsrTimer();
this._clearTimer();
this._startFinalAsrTimer();
return;
}
if (!resolved && this.interDigitTimeout > 0 && this.digitBuffer.length >= this.minDigits) { if (!resolved && this.interDigitTimeout > 0 && this.digitBuffer.length >= this.minDigits) {
/* start interDigitTimer */ /* start interDigitTimer */
const ms = this.interDigitTimeout * 1000; const ms = this.interDigitTimeout * 1000;
@@ -318,86 +196,81 @@ class TaskGather extends Task {
} }
async _initSpeech(cs, ep) { async _initSpeech(cs, ep) {
const opts = this.setChannelVarsForStt(this, this.sttCredentials, this.data.recognizer); try {
this.logger.debug(opts, 'TaskGather:_initSpeech - channel vars'); const opts = {};
switch (this.vendor) {
case 'google': if (this.vad.enable) {
this.bugname = 'google_transcribe'; 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, {
GOOGLE_SPEECH_USE_ENHANCED: true,
GOOGLE_SPEECH_SINGLE_UTTERANCE: true,
GOOGLE_SPEECH_MODEL: 'command_and_search'
});
if (this.hints && this.hints.length > 1) {
opts.GOOGLE_SPEECH_HINTS = this.hints.map((h) => h.trim()).join(',');
}
if (this.altLanguages && this.altLanguages.length > 1) {
opts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
}
if (this.profanityFilter === true) {
Object.assign(opts, {'GOOGLE_SPEECH_PROFANITY_FILTER': true});
}
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep)); ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance, this._onEndOfUtterance.bind(this, cs, ep)); ep.addCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance, this._onEndOfUtterance.bind(this, cs, ep));
ep.addCustomEventListener(GoogleTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep)); }
break; else if (['aws', 'polly'].includes(this.vendor)) {
if (this.vocabularyName) opts.AWS_VOCABULARY_NAME = this.vocabularyName;
case 'aws': if (this.vocabularyFilterName) {
case 'polly': opts.AWS_VOCABULARY_NAME = this.vocabularyFilterName;
this.bugname = 'aws_transcribe'; opts.AWS_VOCABULARY_FILTER_METHOD = this.filterMethod || 'mask';
}
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)); ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(AwsTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep)); }
break; else if ('microsoft' === this.vendor) {
case 'microsoft': if (this.sttCredentials) {
this.bugname = 'azure_transcribe'; 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.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected, ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected,
this._onNoSpeechDetected.bind(this, cs, ep)); this._onNoSpeechDetected.bind(this, cs, ep));
ep.addCustomEventListener(AzureTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep)); }
break; await ep.set(opts)
case 'nuance': .catch((err) => this.logger.error(err, 'Error setting channel variables'));
this.bugname = 'nuance_transcribe'; } catch (err) {
ep.addCustomEventListener(NuanceTranscriptionEvents.Transcription, this.logger.error(err, 'could not init speech for listening');
this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(NuanceTranscriptionEvents.StartOfSpeech,
this._onStartOfSpeech.bind(this, cs, ep));
ep.addCustomEventListener(NuanceTranscriptionEvents.TranscriptionComplete,
this._onTranscriptionComplete.bind(this, cs, ep));
ep.addCustomEventListener(NuanceTranscriptionEvents.VadDetected,
this._onVadDetected.bind(this, cs, ep));
ep.addCustomEventListener(NuanceTranscriptionEvents.Error,
this._onNuanceError.bind(this, cs, ep));
/* stall timers until prompt finishes playing */
if ((this.sayTask || this.playTask) && this.listenDuringPrompt) {
opts.NUANCE_STALL_TIMERS = 1;
}
break;
case 'deepgram':
this.bugname = 'deepgram_transcribe';
ep.addCustomEventListener(DeepgramTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(DeepgramTranscriptionEvents.Connect, this._onDeepgramConnect.bind(this, cs, ep));
ep.addCustomEventListener(DeepgramTranscriptionEvents.ConnectFailure,
this._onDeepGramConnectFailure.bind(this, cs, ep));
break;
case 'ibm':
this.bugname = 'ibm_transcribe';
ep.addCustomEventListener(IbmTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(IbmTranscriptionEvents.Connect, this._onIbmConnect.bind(this, cs, ep));
ep.addCustomEventListener(IbmTranscriptionEvents.ConnectFailure,
this._onIbmConnectFailure.bind(this, cs, ep));
ep.addCustomEventListener(IbmTranscriptionEvents.Error,
this._onIbmError.bind(this, cs, ep));
break;
default:
throw new Error(`Invalid vendor ${this.vendor}`);
} }
await ep.set(opts)
.catch((err) => this.logger.info(err, 'Error setting channel variables'));
} }
_startTranscribing(ep) { _startTranscribing(ep) {
this.logger.debug({
vendor: this.vendor,
locale: this.language,
interim: this.interim,
bugname: this.bugname
}, 'Gather:_startTranscribing');
ep.startTranscription({ ep.startTranscription({
vendor: this.vendor, vendor: this.vendor,
locale: this.language, locale: this.language,
interim: this.interim, interim: this.partialResultCallback || this.bargein,
bugname: this.bugname,
}).catch((err) => { }).catch((err) => {
const {writeAlerts, AlertType} = this.cs.srf.locals; const {writeAlerts, AlertType} = this.cs.srf.locals;
this.logger.error(err, 'TaskGather:_startTranscribing error'); this.logger.error(err, 'TaskGather:_startTranscribing error');
@@ -411,12 +284,9 @@ class TaskGather extends Task {
} }
_startTimer() { _startTimer() {
if (0 === this.timeout) return; assert(!this._timeoutTimer);
this._clearTimer(); this.logger.debug(`Gather:_startTimer: timeout ${this.timeout}`);
this._timeoutTimer = setTimeout(() => { this._timeoutTimer = setTimeout(() => this._resolve('timeout'), this.timeout);
if (this.isContinuousAsr) this._startAsrTimer();
else this._resolve(this.digitBuffer.length >= this.minDigits ? 'dtmf-num-digits' : 'timeout');
}, this.timeout);
} }
_clearTimer() { _clearTimer() {
@@ -426,45 +296,7 @@ class TaskGather extends Task {
} }
} }
_startAsrTimer() {
assert(this.isContinuousAsr);
this._clearAsrTimer();
this._asrTimer = setTimeout(() => {
this.logger.debug('_startAsrTimer - asr timer went off');
this._resolve(this._bufferedTranscripts.length > 0 ? 'speech' : 'timeout');
}, this.asrTimeout);
this.logger.debug(`_startAsrTimer: set for ${this.asrTimeout}ms`);
}
_clearAsrTimer() {
if (this._asrTimer) clearTimeout(this._asrTimer);
this._asrTimer = null;
}
_startFinalAsrTimer() {
this._clearFinalAsrTimer();
this._finalAsrTimer = setTimeout(() => {
this.logger.debug('_startFinalAsrTimer - final asr timer went off');
this._resolve(this._bufferedTranscripts.length > 0 ? 'speech' : 'timeout');
}, 1000);
this.logger.debug('_startFinalAsrTimer: set for 1 second');
}
_clearFinalAsrTimer() {
if (this._finalAsrTimer) clearTimeout(this._finalAsrTimer);
this._finalAsrTimer = null;
}
_killAudio(cs) { _killAudio(cs) {
if (!this.sayTask && !this.playTask && this.bargein) {
if (this.ep?.connected && !this.playComplete) {
this.logger.debug('Gather:_killAudio: killing playback of any audio');
this.playComplete = true;
this.ep.api('uuid_break', this.ep.uuid)
.catch((err) => this.logger.info(err, 'Error killing audio'));
}
return;
}
if (this.sayTask && !this.sayTask.killed) { if (this.sayTask && !this.sayTask.killed) {
this.sayTask.removeAllListeners('playDone'); this.sayTask.removeAllListeners('playDone');
this.sayTask.kill(cs); this.sayTask.kill(cs);
@@ -477,232 +309,87 @@ class TaskGather extends Task {
} }
} }
_onTranscription(cs, ep, evt, fsEvent) { _onTranscription(cs, ep, evt) {
// make sure this is not a transcript from answering machine detection if ('aws' === this.vendor && Array.isArray(evt) && evt.length > 0) evt = evt[0];
const bugname = fsEvent.getHeader('media-bugname'); if ('microsoft' === this.vendor) {
const finished = fsEvent.getHeader('transcription-session-finished'); const final = evt.RecognitionStatus === 'Success';
this.logger.debug({evt, bugname, finished}, 'Gather:_onTranscription'); if (final) {
if (bugname && this.bugname !== bugname) return; const nbest = evt.NBest;
evt = {
evt = this.normalizeTranscription(evt, this.vendor, 1, this.language); is_final: true,
alternatives: [
/* count words for bargein feature */ {
const words = evt.alternatives[0]?.transcript.split(' ').length; confidence: nbest[0].Confidence,
const bufferedWords = this._bufferedTranscripts.reduce((count, e) => { transcript: nbest[0].Display
return count + e.alternatives[0]?.transcript.split(' ').length; }
}, 0); ]
};
if (evt.is_final) {
if (evt.alternatives[0].transcript === '' && !this.callSession.callGone && !this.killed) {
if (finished === 'true' && ['microsoft', 'deepgram'].includes(this.vendor)) {
this.logger.debug({evt}, 'TaskGather:_onTranscription - got empty transcript from old gather, disregarding');
}
else {
this.logger.info({evt}, 'TaskGather:_onTranscription - got empty transcript, continue listening');
//this._startTranscribing(ep);
}
return;
}
if (this.isContinuousAsr) {
/* append the transcript and start listening again for asrTimeout */
const t = evt.alternatives[0].transcript;
if (t) {
/* remove trailing punctuation */
if (/[,;:\.!\?]$/.test(t)) {
this.logger.debug('TaskGather:_onTranscription - removing trailing punctuation');
evt.alternatives[0].transcript = t.slice(0, -1);
}
else this.logger.debug({t}, 'TaskGather:_onTranscription - no trailing punctuation');
}
this.logger.info({evt}, 'TaskGather:_onTranscription - got transcript during continous asr');
this._bufferedTranscripts.push(evt);
this._clearTimer();
if (this._finalAsrTimer) {
this._clearFinalAsrTimer();
return this._resolve(this._bufferedTranscripts.length > 0 ? 'speech' : 'timeout');
}
this._startAsrTimer();
return this._startTranscribing(ep);
} }
else { else {
if (this.bargein && (words + bufferedWords) < this.minBargeinWordCount) { evt = {
this.logger.debug({evt, words, bufferedWords}, is_final: false,
'TaskGather:_onTranscription - final transcript but < min barge words'); alternatives: [
this._bufferedTranscripts.push(evt); {
this._startTranscribing(ep); transcript: evt.Text
return; }
} ]
else { };
this._resolve('speech', evt);
}
} }
} }
if (evt.is_final) this._resolve('speech', evt);
else { else {
/* google has a measure of stability: /* google has a measure of stability:
https://cloud.google.com/speech-to-text/docs/basics#streaming_responses https://cloud.google.com/speech-to-text/docs/basics#streaming_responses
others do not. others do not.
*/ */
//const isStableEnough = typeof evt.stability === 'undefined' || evt.stability > GATHER_STABILITY_THRESHOLD; const isStableEnough = typeof evt.stability === 'undefined' || evt.stability > GATHER_STABILITY_THRESHOLD;
if (this.bargein && (words + bufferedWords) >= this.minBargeinWordCount) {
if (!this.playComplete) { if (this.bargein && isStableEnough &&
this.logger.debug({transcript: evt.alternatives[0].transcript}, 'killing audio due to speech'); evt.alternatives[0].transcript.split(' ').length >= this.minBargeinWordCount) {
this.emit('vad'); this.logger.debug('Gather:_onTranscription - killing audio due to speech bargein');
}
this._killAudio(cs); this._killAudio(cs);
} }
if (this.partialResultHook) { if (this.partialResultHook) {
const b3 = this.getTracingPropagation(); this.cs.requestor.request(this.partialResultHook, Object.assign({speech: evt}, this.cs.callInfo))
const httpHeaders = b3 && {b3}; .catch((err) => this.logger.info(err, 'GatherTask:_onTranscription error'));
this.cs.requestor.request('verb:hook', this.partialResultHook, Object.assign({speech: evt},
this.cs.callInfo, httpHeaders));
} }
} }
} }
_onEndOfUtterance(cs, ep) {
this.logger.debug('TaskGather:_onEndOfUtterance');
if (this.bargein && this.minBargeinWordCount === 0) {
this._killAudio(cs);
}
if (!this.resolved && !this.killed && !this._bufferedTranscripts.length) { _onEndOfUtterance(cs, ep) {
this.logger.info('TaskGather:_onEndOfUtterance');
if (!this.resolved && !this.killed) {
this._startTranscribing(ep); this._startTranscribing(ep);
} }
} }
_onStartOfSpeech(cs, ep) { _onNoSpeechDetected(cs, ep) {
this.logger.debug('TaskGather:_onStartOfSpeech'); this._resolve('timeout');
}
_onTranscriptionComplete(cs, ep) {
this.logger.debug('TaskGather:_onTranscriptionComplete');
}
_onNuanceError(cs, ep, evt) {
const {code, error, details} = evt;
if (code === 404 && error === 'No speech') {
this.logger.debug({code, error, details}, 'TaskGather:_onNuanceError');
return this._resolve('timeout');
}
this.logger.info({code, error, details}, 'TaskGather:_onNuanceError');
if (code === 413 && error === 'Too much speech') {
return this._resolve('timeout');
}
}
_onDeepgramConnect(_cs, _ep) {
this.logger.debug('TaskGather:_onDeepgramConnect');
}
_onDeepGramConnectFailure(cs, _ep, evt) {
const {reason} = evt;
const {writeAlerts, AlertType} = cs.srf.locals;
this.logger.info({evt}, 'TaskGather:_onDeepgramConnectFailure');
writeAlerts({
account_sid: cs.accountSid,
alert_type: AlertType.STT_FAILURE,
message: `Failed connecting to Deepgram speech recognizer: ${reason}`,
vendor: 'deepgram',
}).catch((err) => this.logger.info({err}, 'Error generating alert for deepgram connection failure'));
this.notifyError(`Failed connecting to speech vendor deepgram: ${reason}`);
this.notifyTaskDone();
}
_onIbmConnect(_cs, _ep) {
this.logger.debug('TaskGather:_onIbmConnect');
}
_onIbmConnectFailure(cs, _ep, evt) {
const {reason} = evt;
const {writeAlerts, AlertType} = cs.srf.locals;
this.logger.info({evt}, 'TaskGather:_onIbmConnectFailure');
writeAlerts({
account_sid: cs.accountSid,
alert_type: AlertType.STT_FAILURE,
message: `Failed connecting to IBM watson speech recognizer: ${reason}`,
vendor: 'ibm',
}).catch((err) => this.logger.info({err}, 'Error generating alert for IBM connection failure'));
this.notifyError(`Failed connecting to speech vendor IBM: ${reason}`);
this.notifyTaskDone();
}
_onIbmError(cs, _ep, evt) {
this.logger.info({evt}, 'TaskGather:_onIbmError'); }
_onVadDetected(cs, ep) {
if (this.bargein && this.minBargeinWordCount === 0) {
this.logger.debug('TaskGather:_onVadDetected');
this._killAudio(cs);
this.emit('vad');
}
}
_onNoSpeechDetected(cs, ep, evt, fsEvent) {
if (!this.callSession.callGone && !this.killed) {
const finished = fsEvent.getHeader('transcription-session-finished');
if (this.vendor === 'microsoft' && finished === 'true') {
this.logger.debug('TaskGather:_onNoSpeechDetected for old gather, ignoring');
}
else {
this.logger.debug('TaskGather:_onNoSpeechDetected - listen again');
this._startTranscribing(ep);
}
return;
}
} }
async _resolve(reason, evt) { async _resolve(reason, evt) {
this.logger.debug(`TaskGather:resolve with reason ${reason}`);
if (this.resolved) return; if (this.resolved) return;
this.resolved = true; this.resolved = true;
this.logger.debug(`TaskGather:resolve with reason ${reason}`);
clearTimeout(this.interDigitTimer); clearTimeout(this.interDigitTimer);
this._clearTimer();
if (this.isContinuousAsr && reason.startsWith('speech')) { if (this.ep && this.ep.connected) {
evt = {
is_final: true,
transcripts: this._bufferedTranscripts
};
this.logger.debug({evt}, 'TaskGather:resolve continuous asr');
}
else if (!this.isContinuousAsr && reason.startsWith('speech') && this._bufferedTranscripts.length) {
compileTranscripts(this.logger, evt, this._bufferedTranscripts);
this.logger.debug({evt}, 'TaskGather:resolve buffered results');
}
this.span.setAttributes({'stt.resolve': reason, 'stt.result': JSON.stringify(evt)});
if (this.needsStt && this.ep && this.ep.connected) {
this.ep.stopTranscription({vendor: this.vendor}) this.ep.stopTranscription({vendor: this.vendor})
.catch((err) => this.logger.error({err}, 'Error stopping transcription')); .catch((err) => this.logger.error({err}, 'Error stopping transcription'));
} }
if (this.callSession && this.callSession.callGone) { this._clearTimer();
this.logger.debug('TaskGather:_resolve - call is gone, not invoking web callback'); if (reason.startsWith('dtmf')) {
this.notifyTaskDone(); await this.performAction({digits: this.digitBuffer, reason: 'dtmfDetected'});
return; }
else if (reason.startsWith('speech')) {
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'});
} }
try {
if (reason.startsWith('dtmf')) {
if (this.parentTask) this.parentTask.emit('dtmf', evt);
else {
this.emit('dtmf', evt);
await this.performAction({digits: this.digitBuffer, reason: 'dtmfDetected'});
}
}
else if (reason.startsWith('speech')) {
if (this.parentTask) this.parentTask.emit('transcription', evt);
else {
this.emit('transcription', evt);
await this.performAction({speech: evt, reason: 'speechDetected'});
}
}
else if (reason.startsWith('timeout')) {
if (this.parentTask) this.parentTask.emit('timeout', evt);
else {
this.emit('timeout', evt);
await this.performAction({reason: 'timeout'});
}
}
} catch (err) { /*already logged error*/ }
this.notifyTaskDone(); this.notifyTaskDone();
} }
} }

View File

@@ -14,7 +14,7 @@ class TaskHangup extends Task {
/** /**
* Hangup the call * Hangup the call
*/ */
async exec(cs, {dlg}) { async exec(cs, dlg) {
await super.exec(cs); await super.exec(cs);
try { try {
await dlg.destroy({headers: this.headers}); await dlg.destroy({headers: this.headers});

View File

@@ -8,7 +8,7 @@ class TaskLeave extends Task {
get name() { return TaskName.Leave; } get name() { return TaskName.Leave; }
async exec(cs, {ep}) { async exec(cs, ep) {
await super.exec(cs); await super.exec(cs);
await this.awaitTaskDone(); await this.awaitTaskDone();
} }

View File

@@ -44,7 +44,7 @@ class Lex extends Task {
get name() { return TaskName.Lex; } get name() { return TaskName.Lex; }
async exec(cs, {ep}) { async exec(cs, ep) {
await super.exec(cs); await super.exec(cs);
try { try {
@@ -289,9 +289,7 @@ class Lex extends Task {
} }
async _performHook(cs, hook, results) { async _performHook(cs, hook, results) {
const b3 = this.getTracingPropagation(); const json = await this.cs.requestor.request('verb:hook', hook, results);
const httpHeaders = b3 && {b3};
const json = await this.cs.requestor.request('verb:hook', hook, results, httpHeaders);
if (json && Array.isArray(json)) { if (json && Array.isArray(json)) {
const makeTask = require('./make_task'); const makeTask = require('./make_task');
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata)); const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));

View File

@@ -22,14 +22,15 @@ class TaskListen extends Task {
this.results = {}; this.results = {};
if (this.transcribe) this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe}, this); if (this.transcribe) this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe}, this);
this._dtmfHandler = this._onDtmf.bind(this);
} }
get name() { return TaskName.Listen; } get name() { return TaskName.Listen; }
async exec(cs, {ep}) { async exec(cs, ep) {
await super.exec(cs); await super.exec(cs);
this.ep = ep; this.ep = ep;
this._dtmfHandler = this._onDtmf.bind(this, ep);
try { try {
this.hook = this.normalizeUrl(this.url, 'GET', this.wsAuth); this.hook = this.normalizeUrl(this.url, 'GET', this.wsAuth);
@@ -37,12 +38,7 @@ class TaskListen extends Task {
if (this.playBeep) await this._playBeep(ep); if (this.playBeep) await this._playBeep(ep);
if (this.transcribeTask) { if (this.transcribeTask) {
this.logger.debug('TaskListen:exec - starting nested transcribe task'); this.logger.debug('TaskListen:exec - starting nested transcribe task');
const {span, ctx} = this.startChildSpan(`nested:${this.transcribeTask.summary}`); this.transcribeTask.exec(cs, ep);
this.transcribeTask.span = span;
this.transcribeTask.ctx = ctx;
this.transcribeTask.exec(cs, {ep})
.then((result) => span.end())
.catch((err) => span.end());
} }
await this._startListening(cs, ep); await this._startListening(cs, ep);
await this.awaitTaskDone(); await this.awaitTaskDone();
@@ -60,21 +56,14 @@ class TaskListen extends Task {
this._clearTimer(); this._clearTimer();
if (this.ep && this.ep.connected) { if (this.ep && this.ep.connected) {
this.logger.debug('TaskListen:kill closing websocket'); this.logger.debug('TaskListen:kill closing websocket');
try { await this.ep.forkAudioStop()
await this.ep.forkAudioStop(); .catch((err) => this.logger.info(err, 'TaskListen:kill'));
this.logger.debug('TaskListen:kill successfully closed websocket');
} catch (err) {
this.logger.info(err, 'TaskListen:kill');
}
} }
if (this.recordStartTime) { if (this.recordStartTime) {
const duration = moment().diff(this.recordStartTime, 'seconds'); const duration = moment().diff(this.recordStartTime, 'seconds');
this.results.dialCallDuration = duration; this.results.dialCallDuration = duration;
} }
if (this.transcribeTask) { if (this.transcribeTask) await this.transcribeTask.kill(cs);
await this.transcribeTask.kill(cs);
this.transcribeTask = null;
}
this.ep && this._removeListeners(this.ep); this.ep && this._removeListeners(this.ep);
this.notifyTaskDone(); this.notifyTaskDone();
} }
@@ -154,13 +143,7 @@ class TaskListen extends Task {
} }
_onDtmf(ep, evt) { _onDtmf(evt) {
this.logger.debug({evt}, `TaskListen:_onDtmf received dtmf ${evt.dtmf}`);
if (this.passDtmf && this.ep?.connected) {
const obj = {event: 'dtmf', dtmf: evt.dtmf, duration: evt.duration};
this.ep.forkAudioSendText(obj)
.catch((err) => this.logger.info({err}, 'TaskListen:_onDtmf error sending dtmf'));
}
if (evt.dtmf === this.finishOnKey) { if (evt.dtmf === this.finishOnKey) {
this.logger.info(`TaskListen:_onDtmf terminating task due to dtmf ${evt.dtmf}`); this.logger.info(`TaskListen:_onDtmf terminating task due to dtmf ${evt.dtmf}`);
this.results.digits = evt.dtmf; this.results.digits = evt.dtmf;
@@ -219,7 +202,7 @@ class TaskListen extends Task {
this.logger.debug('Listen:whisper tasks starting'); this.logger.debug('Listen:whisper tasks starting');
while (tasks.length && !cs.callGone) { while (tasks.length && !cs.callGone) {
const task = tasks.shift(); const task = tasks.shift();
await task.exec(cs, {ep: this.ep}); await task.exec(cs, this.ep);
} }
this.logger.debug('Listen:whisper tasks complete'); this.logger.debug('Listen:whisper tasks complete');
} catch (err) { } catch (err) {

View File

@@ -17,15 +17,12 @@ function makeTask(logger, obj, parent) {
case TaskName.SipDecline: case TaskName.SipDecline:
const TaskSipDecline = require('./sip_decline'); const TaskSipDecline = require('./sip_decline');
return new TaskSipDecline(logger, data, parent); return new TaskSipDecline(logger, data, parent);
case TaskName.SipRequest:
const TaskSipRequest = require('./sip_request');
return new TaskSipRequest(logger, data, parent);
case TaskName.SipRefer: case TaskName.SipRefer:
const TaskSipRefer = require('./sip_refer'); const TaskSipRefer = require('./sip_refer');
return new TaskSipRefer(logger, data, parent); return new TaskSipRefer(logger, data, parent);
case TaskName.Config: case TaskName.Cognigy:
const TaskConfig = require('./config'); const TaskCognigy = require('./cognigy');
return new TaskConfig(logger, data, parent); return new TaskCognigy(logger, data, parent);
case TaskName.Conference: case TaskName.Conference:
const TaskConference = require('./conference'); const TaskConference = require('./conference');
return new TaskConference(logger, data, parent); return new TaskConference(logger, data, parent);

View File

@@ -1,7 +1,7 @@
const Task = require('./task'); const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants'); const {TaskName, TaskPreconditions} = require('../utils/constants');
const bent = require('bent'); const bent = require('bent');
const uuidv4 = require('uuid-random'); const { v4: uuidv4 } = require('uuid');
class TaskMessage extends Task { class TaskMessage extends Task {
constructor(logger, opts) { constructor(logger, opts) {

View File

@@ -10,7 +10,7 @@ class TaskPause extends Task {
get name() { return TaskName.Pause; } get name() { return TaskName.Pause; }
async exec(cs) { async exec(cs, ep) {
await super.exec(cs); await super.exec(cs);
this.timer = setTimeout(this.notifyTaskDone.bind(this), this.length * 1000); this.timer = setTimeout(this.notifyTaskDone.bind(this), this.length * 1000);
await this.awaitTaskDone(); await this.awaitTaskDone();

View File

@@ -7,65 +7,24 @@ class TaskPlay extends Task {
this.preconditions = TaskPreconditions.Endpoint; this.preconditions = TaskPreconditions.Endpoint;
this.url = this.data.url; this.url = this.data.url;
this.seekOffset = this.data.seekOffset || -1;
this.timeoutSecs = this.data.timeoutSecs || -1;
this.loop = this.data.loop || 1; this.loop = this.data.loop || 1;
this.earlyMedia = this.data.earlyMedia === true; this.earlyMedia = this.data.earlyMedia === true;
} }
get name() { return TaskName.Play; } get name() { return TaskName.Play; }
get summary() { async exec(cs, ep) {
return `${this.name}:{url=${this.url}}`;
}
async exec(cs, {ep}) {
await super.exec(cs); await super.exec(cs);
this.ep = ep; this.ep = ep;
let timeout;
let playbackSeconds = 0;
let playbackMilliseconds = 0;
let completed = !(this.timeoutSecs > 0 || this.loop);
if (this.timeoutSecs > 0) {
timeout = setTimeout(async() => {
completed = true;
try {
await this.kill(cs);
} catch (err) {
this.logger.info(err, 'Error killing audio on timeoutSecs');
}
}, this.timeoutSecs * 1000);
}
try { try {
while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep.connected) { while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep.connected) {
if (cs.isInConference) { if (cs.isInConference) {
const {memberId, confName, confUuid} = cs; const {memberId, confName, confUuid} = cs;
if (Array.isArray(this.url)) { await this.playToConfMember(this.ep, memberId, confName, confUuid, this.url);
for (const playUrl of this.url) {
await this.playToConfMember(this.ep, memberId, confName, confUuid, playUrl);
}
} else {
await this.playToConfMember(this.ep, memberId, confName, confUuid, this.url);
}
} else {
let file = this.url;
if (this.seekOffset >= 0) {
file = {file: this.url, seekOffset: this.seekOffset};
this.seekOffset = -1;
}
const result = await ep.play(file);
playbackSeconds += parseInt(result.playbackSeconds);
playbackMilliseconds += parseInt(result.playbackMilliseconds);
if (this.killed || !this.loop || completed) {
if (timeout) clearTimeout(timeout);
await this.performAction(
Object.assign(result, {reason: 'playCompleted', playbackSeconds, playbackMilliseconds}),
!(this.parentTask || cs.isConfirmCallSession));
}
} }
else await ep.play(this.url);
} }
} catch (err) { } catch (err) {
if (timeout) clearTimeout(timeout);
this.logger.info(err, `TaskPlay:exec - error playing ${this.url}`); this.logger.info(err, `TaskPlay:exec - error playing ${this.url}`);
} }
this.emit('playDone'); this.emit('playDone');

View File

@@ -20,7 +20,7 @@ class Rasa extends Task {
return this.reportedFinalAction || this.isReplacingApplication; return this.reportedFinalAction || this.isReplacingApplication;
} }
async exec(cs, {ep}) { async exec(cs, ep) {
await super.exec(cs); await super.exec(cs);
this.ep = ep; this.ep = ep;
@@ -31,15 +31,8 @@ class Rasa extends Task {
/* start the first gather */ /* start the first gather */
this.gatherTask = this._makeGatherTask(this.prompt); this.gatherTask = this._makeGatherTask(this.prompt);
const {span, ctx} = this.startChildSpan(`nested:${this.gatherTask.summary}`); this.gatherTask.exec(cs, ep, this)
this.gatherTask.span = span; .catch((err) => this.logger.info({err}, 'Rasa gather task returned error'));
this.gatherTask.ctx = ctx;
this.gatherTask.exec(cs, {ep})
.then(() => span.end())
.catch((err) => {
span.end();
this.logger.info({err}, 'Rasa gather task returned error');
});
await this.awaitTaskDone(); await this.awaitTaskDone();
} catch (err) { } catch (err) {
@@ -125,15 +118,8 @@ class Rasa extends Task {
if (botUtterance) { if (botUtterance) {
this.logger.debug({botUtterance}, 'Rasa:_onTranscription: got user utterance'); this.logger.debug({botUtterance}, 'Rasa:_onTranscription: got user utterance');
this.gatherTask = this._makeGatherTask(botUtterance); this.gatherTask = this._makeGatherTask(botUtterance);
const {span, ctx} = this.startChildSpan(`nested:${this.gatherTask.summary}`); this.gatherTask.exec(cs, ep, this)
this.gatherTask.span = span; .catch((err) => this.logger.info({err}, 'Rasa gather task returned error'));
this.gatherTask.ctx = ctx;
this.gatherTask.exec(cs, {ep})
.then(() => span.end())
.catch((err) => {
span.end();
this.logger.info({err}, 'Rasa gather task returned error');
});
if (this.eventHook) { if (this.eventHook) {
this.performHook(cs, this.eventHook, {event: 'botMessage', message: response}) this.performHook(cs, this.eventHook, {event: 'botMessage', message: response})
.then((redirected) => { .then((redirected) => {

View File

@@ -11,7 +11,6 @@ class TaskRestDial extends Task {
super(logger, opts); super(logger, opts);
this.from = this.data.from; this.from = this.data.from;
this.fromHost = this.data.fromHost;
this.to = this.data.to; this.to = this.data.to;
this.call_hook = this.data.call_hook; this.call_hook = this.data.call_hook;
this.timeout = this.data.timeout || 60; this.timeout = this.data.timeout || 60;
@@ -49,23 +48,7 @@ class TaskRestDial extends Task {
cs.setDialog(dlg); cs.setDialog(dlg);
try { try {
const b3 = this.getTracingPropagation(); const tasks = await cs.requestor.request('verb:hook', this.call_hook, cs.callInfo);
const httpHeaders = b3 && {b3};
const params = {
...cs.callInfo,
defaults: {
synthesizer: {
vendor: cs.speechSynthesisVendor,
language: cs.speechSynthesisLanguage,
voice: cs.speechSynthesisVoice
},
recognizer: {
vendor: cs.speechRecognizerVendor,
language: cs.speechRecognizerLanguage
}
}
};
const tasks = await cs.requestor.request('session:new', this.call_hook, params, httpHeaders);
if (tasks && Array.isArray(tasks)) { if (tasks && Array.isArray(tasks)) {
this.logger.debug({tasks: tasks}, `TaskRestDial: replacing application with ${tasks.length} tasks`); this.logger.debug({tasks: tasks}, `TaskRestDial: replacing application with ${tasks.length} tasks`);
cs.replaceApplication(normalizeJambones(this.logger, tasks).map((tdata) => makeTask(this.logger, tdata))); cs.replaceApplication(normalizeJambones(this.logger, tasks).map((tdata) => makeTask(this.logger, tdata)));

View File

@@ -23,7 +23,7 @@ class TaskSayLegacy extends Task {
get name() { return TaskName.SayLegacy; } get name() { return TaskName.SayLegacy; }
async exec(cs, {ep}) { async exec(cs, ep) {
super.exec(cs); super.exec(cs);
this.ep = ep; this.ep = ep;
try { try {

View File

@@ -1,124 +1,20 @@
const Task = require('./task'); const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants'); const {TaskName, TaskPreconditions} = require('../utils/constants');
const breakLengthyTextIfNeeded = (logger, text) => {
const chunkSize = 1000;
if (text.length <= chunkSize) return [text];
const result = [];
const isSSML = text.startsWith('<speak>');
let startPos = 0;
let charPos = isSSML ? 7 : 0; // skip <speak>
let tag;
//logger.debug({isSSML}, `breakLengthyTextIfNeeded: handling text of length ${text.length}`);
while (startPos + charPos < text.length) {
if (isSSML && !tag && text[startPos + charPos] === '<') {
const tagStartPos = ++charPos;
while (startPos + charPos < text.length) {
if (text[startPos + charPos] === '>') {
if (text[startPos + charPos - 1] === '\\') tag = null;
else if (!tag) tag = text.substring(startPos + tagStartPos, startPos + charPos - 1);
break;
}
if (!tag) {
const c = text[startPos + charPos];
if (c === ' ') {
tag = text.substring(startPos + tagStartPos, startPos + charPos);
//logger.debug(`breakLengthyTextIfNeeded: enter tag ${tag} (space)`);
break;
}
}
charPos++;
}
if (tag) {
//search for end of tag
//logger.debug(`breakLengthyTextIfNeeded: searching forward for </${tag}>`);
const e1 = text.indexOf(`</${tag}>`, startPos + charPos);
const e2 = text.indexOf('/>', startPos + charPos);
const tagEndPos = e1 === -1 ? e2 : e2 === -1 ? e1 : Math.min(e1, e2);
if (tagEndPos === -1) {
//logger.debug(`breakLengthyTextIfNeeded: exit tag ${tag} not found, exiting`);
} else {
//logger.debug(`breakLengthyTextIfNeeded: exit tag ${tag} found at ${tagEndPos}`);
charPos = tagEndPos + 1;
}
tag = null;
}
continue;
}
if (charPos < chunkSize) {
charPos++;
continue;
}
// start looking for a good break point
let chunkIt = false;
const a = text[startPos + charPos];
const b = text[startPos + charPos + 1];
if (/[\.!\?]/.test(a) && /\s/.test(b)) {
//logger.debug('breakLengthyTextIfNeeded: breaking at sentence end');
chunkIt = true;
}
if (chunkIt) {
charPos++;
const chunk = text.substr(startPos, charPos);
if (isSSML) {
result.push(0 === startPos ? `${chunk}</speak>` : `<speak>${chunk}</speak>`);
}
else result.push(chunk);
charPos = 0;
startPos += chunk.length;
//logger.debug({chunk: result[result.length - 1]},
// `breakLengthyTextIfNeeded: chunked; new starting pos ${startPos}`);
}
else charPos++;
}
// final chunk
if (startPos < text.length) {
const chunk = text.substr(startPos);
if (isSSML) {
result.push(0 === startPos ? `${chunk}</speak>` : `<speak>${chunk}`);
}
else result.push(chunk);
//logger.debug({chunk: result[result.length - 1]},
// `breakLengthyTextIfNeeded: final chunk; starting pos ${startPos} length ${chunk.length}`);
}
return result;
};
class TaskSay extends Task { class TaskSay extends Task {
constructor(logger, opts, parentTask) { constructor(logger, opts, parentTask) {
super(logger, opts); super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint; this.preconditions = TaskPreconditions.Endpoint;
this.text = (Array.isArray(this.data.text) ? this.data.text : [this.data.text]) this.text = Array.isArray(this.data.text) ? this.data.text : [this.data.text];
.map((t) => breakLengthyTextIfNeeded(this.logger, t))
.flat();
this.loop = this.data.loop || 1; this.loop = this.data.loop || 1;
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia); this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
this.synthesizer = this.data.synthesizer || {}; this.synthesizer = this.data.synthesizer || {};
this.disableTtsCache = this.data.disableTtsCache;
} }
get name() { return TaskName.Say; } get name() { return TaskName.Say; }
get summary() { async exec(cs, ep) {
for (let i = 0; i < this.text.length; i++) {
if (this.text[i].startsWith('silence_stream')) continue;
return `${this.name}{text=${this.text[i].slice(0, 15)}${this.text[i].length > 15 ? '...' : ''}}`;
}
return `${this.name}{${this.text[0]}}`;
}
async exec(cs, {ep}) {
await super.exec(cs); await super.exec(cs);
const {srf} = cs; const {srf} = cs;
@@ -131,24 +27,14 @@ class TaskSay extends Task {
const language = this.synthesizer.language && this.synthesizer.language !== 'default' ? const language = this.synthesizer.language && this.synthesizer.language !== 'default' ?
this.synthesizer.language : this.synthesizer.language :
cs.speechSynthesisLanguage ; cs.speechSynthesisLanguage ;
let voice = this.synthesizer.voice && this.synthesizer.voice !== 'default' ? const voice = this.synthesizer.voice && this.synthesizer.voice !== 'default' ?
this.synthesizer.voice : this.synthesizer.voice :
cs.speechSynthesisVoice; cs.speechSynthesisVoice;
const engine = this.synthesizer.engine || 'standard'; const engine = this.synthesizer.engine || 'standard';
const salt = cs.callSid; const salt = cs.callSid;
const credentials = cs.getSpeechCredentials(vendor, 'tts'); const credentials = cs.getSpeechCredentials(vendor, 'tts');
/* parse Nuance voices into name and model */ this.logger.info({vendor, language, voice}, 'TaskSay:exec');
let model;
if (vendor === 'nuance' && voice) {
const arr = /([A-Za-z-]*)\s+-\s+(enhanced|standard)/.exec(voice);
if (arr) {
voice = arr[1];
model = arr[2];
}
}
this.logger.info({vendor, language, voice, model}, 'TaskSay:exec');
this.ep = ep; this.ep = ep;
try { try {
if (!credentials) { if (!credentials) {
@@ -157,79 +43,44 @@ class TaskSay extends Task {
alert_type: AlertType.TTS_NOT_PROVISIONED, alert_type: AlertType.TTS_NOT_PROVISIONED,
vendor vendor
}).catch((err) => this.logger.info({err}, 'Error generating alert for no tts')); }).catch((err) => this.logger.info({err}, 'Error generating alert for no tts'));
this.notifyError({
msg: 'TTS error',
details:`No speech credentials provisioned for selected vendor ${vendor}`
});
throw new Error('no provisioned speech credentials for TTS'); throw new Error('no provisioned speech credentials for TTS');
} }
// synthesize all of the text elements // synthesize all of the text elements
let lastUpdated = false; let lastUpdated = false;
const filepath = (await Promise.all(this.text.map(async(text) => {
/* produce an audio segment from the provided text */
const generateAudio = async(text) => {
if (this.killed) return;
if (text.startsWith('silence_stream://')) return text; if (text.startsWith('silence_stream://')) return text;
const {filePath, servedFromCache} = await synthAudio(stats, {
/* otel: trace time for tts */ text,
const {span} = this.startChildSpan('tts-generation', { vendor,
'tts.vendor': vendor, language,
'tts.language': language, voice,
'tts.voice': voice engine,
}); salt,
try { credentials
const {filePath, servedFromCache, rtt} = await synthAudio(stats, { }).catch((err) => {
text, this.logger.info(err, 'Error synthesizing tts');
vendor,
language,
voice,
engine,
model,
salt,
credentials,
disableTtsCache : this.disableTtsCache
});
this.logger.debug(`file ${filePath}, served from cache ${servedFromCache}`);
if (filePath) cs.trackTmpFile(filePath);
if (!servedFromCache && !lastUpdated) {
lastUpdated = true;
updateSpeechCredentialLastUsed(credentials.speech_credential_sid)
.catch(() => {/*already logged error */});
}
span.setAttributes({'tts.cached': servedFromCache});
span.end();
if (!servedFromCache && rtt) {
this.notifyStatus({
event: 'synthesized-audio',
vendor,
language,
characters: text.length,
elapsedTime: rtt
});
}
return filePath;
} catch (err) {
this.logger.info({err}, 'Error synthesizing tts');
span.end();
writeAlerts({ writeAlerts({
account_sid: cs.accountSid, account_sid: cs.accountSid,
alert_type: AlertType.TTS_NOT_PROVISIONED, alert_type: AlertType.TTS_NOT_PROVISIONED,
vendor, vendor,
detail: err.message detail: err.message
}).catch((err) => this.logger.info({err}, 'Error generating alert for tts failure')); });
this.notifyError({msg: 'TTS error', details: err.message || err}); }).catch((err) => this.logger.info({err}, 'Error generating alert for tts failure'));
return; this.logger.debug(`file ${filePath}, served from cache ${servedFromCache}`);
if (filePath) cs.trackTmpFile(filePath);
if (!servedFromCache && !lastUpdated) {
lastUpdated = true;
updateSpeechCredentialLastUsed(credentials.speech_credential_sid)
.catch(() => {/*already logged error */});
} }
}; return filePath;
}))).filter((fp) => fp && fp.length);
const arr = this.text.map((t) => generateAudio(t));
const filepath = (await Promise.all(arr)).filter((fp) => fp && fp.length);
this.logger.debug({filepath}, 'synthesized files for tts'); this.logger.debug({filepath}, 'synthesized files for tts');
this.notifyStatus({event: 'start-playback'});
while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep?.connected) { while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep.connected) {
let segment = 0; let segment = 0;
while (!this.killed && segment < filepath.length) { do {
if (cs.isInConference) { if (cs.isInConference) {
const {memberId, confName, confUuid} = cs; const {memberId, confName, confUuid} = cs;
await this.playToConfMember(this.ep, memberId, confName, confUuid, filepath[segment]); await this.playToConfMember(this.ep, memberId, confName, confUuid, filepath[segment]);
@@ -239,8 +90,7 @@ class TaskSay extends Task {
await ep.play(filepath[segment]); await ep.play(filepath[segment]);
this.logger.debug(`Say:exec completed play file ${filepath[segment]}`); this.logger.debug(`Say:exec completed play file ${filepath[segment]}`);
} }
segment++; } while (!this.killed && ++segment < filepath.length);
}
} }
} catch (err) { } catch (err) {
this.logger.info(err, 'TaskSay:exec error'); this.logger.info(err, 'TaskSay:exec error');
@@ -257,7 +107,6 @@ class TaskSay extends Task {
this.killPlayToConfMember(this.ep, memberId, confName); this.killPlayToConfMember(this.ep, memberId, confName);
} }
else { else {
this.notifyStatus({event: 'kill-playback'});
this.ep.api('uuid_break', this.ep.uuid); this.ep.api('uuid_break', this.ep.uuid);
} }
} }

View File

@@ -19,11 +19,7 @@ class TaskSipDecline extends Task {
res.send(this.data.status, this.data.reason, { res.send(this.data.status, this.data.reason, {
headers: this.headers headers: this.headers
}); });
cs.emit('callStatusChange', { cs.emit('callStatusChange', {callStatus: CallStatus.Failed, sipStatus: this.data.status});
callStatus: CallStatus.Failed,
sipStatus: this.data.status,
sipReason: this.data.reason
});
} }
} }

View File

@@ -26,43 +26,31 @@ class TaskSipRefer extends Task {
try { try {
this.notifyHandler = this._handleNotify.bind(this, cs, dlg); this.notifyHandler = this._handleNotify.bind(this, cs, dlg);
dlg.on('notify', this.notifyHandler); dlg.on('notify', this.notifyHandler);
/* otel: trace time for tts */
this.referSpan = this.startSpan('send-refer', {
'refer.refer_to': referTo,
'refer.referred_by': referredBy
});
const response = await dlg.request({ const response = await dlg.request({
method: 'REFER', method: 'REFER',
headers: { headers: {
...this.headers, ...this.headers,
...(this.referToIsUri && {'X-Refer-To-Leave-Untouched': true}),
'Refer-To': referTo, 'Refer-To': referTo,
'Referred-By': referredBy 'Referred-By': referredBy
} }
}); });
this.referStatus = response.status; this.referStatus = response.status;
this.referSpan.setAttributes({'refer.status_code': response.status});
this.logger.info(`TaskSipRefer:exec - received ${this.referStatus} to REFER`); 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 we fail, fall through to next verb. If success, we should get BYE from far end */
if (this.referStatus === 202) { if (this.referStatus === 202) {
await this.awaitTaskDone(); await this.awaitTaskDone();
} }
else { else await this.performAction({refer_status: this.referStatus});
await this.performAction({refer_status: this.referStatus});
}
} catch (err) { } catch (err) {
this.logger.info({err}, 'TaskSipRefer:exec - error sending REFER'); this.logger.info({err}, 'TaskSipRefer:exec - error sending REFER');
} }
this.referSpan?.end();
} }
async kill(cs) { async kill(cs) {
super.kill(cs); super.kill(cs);
const {dlg} = cs; const {dlg} = cs;
dlg.off('notify', this.notifyHandler); dlg.off('notify', this.notifyHandler);
this.notifyTaskDone();
} }
async _handleNotify(cs, dlg, req, res) { async _handleNotify(cs, dlg, req, res) {
@@ -77,13 +65,9 @@ class TaskSipRefer extends Task {
const status = arr[1]; const status = arr[1];
this.logger.debug(`TaskSipRefer:_handleNotify: call got status ${status}`); this.logger.debug(`TaskSipRefer:_handleNotify: call got status ${status}`);
if (this.eventHook) { if (this.eventHook) {
const b3 = this.getTracingPropagation(); await cs.requestor.request('verb:hook', this.eventHook, {event: 'transfer-status', call_status: status});
const httpHeaders = b3 && {b3};
await cs.requestor.request('verb:hook', this.eventHook,
{event: 'transfer-status', call_status: status}, httpHeaders);
} }
if (status >= 200) { if (status >= 200) {
this.referSpan.setAttributes({'refer.finalNotify': status});
await this.performAction({refer_status: 202, final_referred_call_status: status}); await this.performAction({refer_status: 202, final_referred_call_status: status});
this.notifyTaskDone(); this.notifyTaskDone();
} }
@@ -101,7 +85,6 @@ class TaskSipRefer extends Task {
/* they may have only provided a phone number/user */ /* they may have only provided a phone number/user */
referTo = `sip:${referTo}@${host}`; referTo = `sip:${referTo}@${host}`;
} }
else this.referToIsUri = true;
if (!referredBy) { if (!referredBy) {
/* default */ /* default */
referredBy = cs.req?.callingNumber || dlg.local.uri; referredBy = cs.req?.callingNumber || dlg.local.uri;

View File

@@ -1,49 +0,0 @@
const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants');
/**
* Send a SIP request (e.g. INFO, NOTIFY, etc) on an existing call leg
*/
class TaskSipRequest extends Task {
constructor(logger, opts) {
super(logger, opts);
this.preconditions = TaskPreconditions.StableCall;
this.method = this.data.method.toUpperCase();
this.headers = this.data.headers || {};
this.body = this.data.body;
if (this.body) this.body = `${this.body}\n`;
}
get name() { return TaskName.SipRequest; }
async exec(cs, {dlg}) {
super.exec(cs);
try {
this.logger.info({dlg}, `TaskSipRequest: sending a SIP ${this.method}`);
const res = await dlg.request({
method: this.method,
headers: this.headers,
body: this.body
});
const result = {result: 'success', sipStatus: res.status};
this.span.setAttributes({
...this.headers,
...(this.body && {body: this.body}),
'response.status_code': res.status
});
this.logger.debug({result}, `TaskSipRequest: received response to ${this.method}`);
await this.performAction(result);
} catch (err) {
this.logger.error({err}, 'TaskSipRequest: error');
this.span.setAttributes({
...this.headers,
...(this.body && {body: this.body}),
'response.error': err.message
});
await this.performAction({result: 'failed', err: err.message});
}
}
}
module.exports = TaskSipRequest;

View File

@@ -1,7 +1,6 @@
{ {
"sip:decline": { "sip:decline": {
"properties": { "properties": {
"id": "string",
"status": "number", "status": "number",
"reason": "string", "reason": "string",
"headers": "object" "headers": "object"
@@ -10,21 +9,8 @@
"status" "status"
] ]
}, },
"sip:request": {
"properties": {
"id": "string",
"method": "string",
"body": "string",
"headers": "object",
"actionHook": "object|string"
},
"required": [
"method"
]
},
"sip:refer": { "sip:refer": {
"properties": { "properties": {
"id": "string",
"referTo": "string", "referTo": "string",
"referredBy": "string", "referredBy": "string",
"headers": "object", "headers": "object",
@@ -35,39 +21,24 @@
"referTo" "referTo"
] ]
}, },
"config": { "cognigy": {
"properties": { "properties": {
"id": "string", "url": "string",
"synthesizer": "#synthesizer", "token": "string",
"recognizer": "#recognizer", "recognizer": "#recognizer",
"bargeIn": "#bargeIn", "tts": "#synthesizer",
"record": "#recordOptions", "prompt": "string",
"amd": "#amd",
"notifyEvents": "boolean"
},
"required": []
},
"bargeIn": {
"properties": {
"enable": "boolean",
"sticky": "boolean",
"actionHook": "object|string", "actionHook": "object|string",
"input": "array", "eventHook": "object|string",
"finishOnKey": "string", "data": "object"
"numDigits": "number",
"minDigits": "number",
"maxDigits": "number",
"interDigitTimeout": "number",
"dtmfBargein": "boolean",
"minBargeinWordCount": "number"
}, },
"required": [ "required": [
"enable" "url",
"token"
] ]
}, },
"dequeue": { "dequeue": {
"properties": { "properties": {
"id": "string",
"name": "string", "name": "string",
"actionHook": "object|string", "actionHook": "object|string",
"timeout": "number", "timeout": "number",
@@ -79,7 +50,6 @@
}, },
"enqueue": { "enqueue": {
"properties": { "properties": {
"id": "string",
"name": "string", "name": "string",
"actionHook": "object|string", "actionHook": "object|string",
"waitHook": "object|string", "waitHook": "object|string",
@@ -91,12 +61,11 @@
}, },
"leave": { "leave": {
"properties": { "properties": {
"id": "string"
} }
}, },
"hangup": { "hangup": {
"properties": { "properties": {
"id": "string",
"headers": "object" "headers": "object"
}, },
"required": [ "required": [
@@ -104,13 +73,9 @@
}, },
"play": { "play": {
"properties": { "properties": {
"id": "string", "url": "string",
"url": "string|array",
"loop": "number|string", "loop": "number|string",
"earlyMedia": "boolean", "earlyMedia": "boolean"
"seekOffset": "number|string",
"timeoutSecs": "number|string",
"actionHook": "object|string"
}, },
"required": [ "required": [
"url" "url"
@@ -118,12 +83,10 @@
}, },
"say": { "say": {
"properties": { "properties": {
"id": "string",
"text": "string|array", "text": "string|array",
"loop": "number|string", "loop": "number|string",
"synthesizer": "#synthesizer", "synthesizer": "#synthesizer",
"earlyMedia": "boolean", "earlyMedia": "boolean"
"disableTtsCache": "boolean"
}, },
"required": [ "required": [
"text" "text"
@@ -131,7 +94,6 @@
}, },
"gather": { "gather": {
"properties": { "properties": {
"id": "string",
"actionHook": "object|string", "actionHook": "object|string",
"finishOnKey": "string", "finishOnKey": "string",
"input": "array", "input": "array",
@@ -142,6 +104,7 @@
"partialResultHook": "object|string", "partialResultHook": "object|string",
"speechTimeout": "number", "speechTimeout": "number",
"listenDuringPrompt": "boolean", "listenDuringPrompt": "boolean",
"listenAfterSpeech": "boolean",
"dtmfBargein": "boolean", "dtmfBargein": "boolean",
"bargein": "boolean", "bargein": "boolean",
"minBargeinWordCount": "number", "minBargeinWordCount": "number",
@@ -155,7 +118,6 @@
}, },
"conference": { "conference": {
"properties": { "properties": {
"id": "string",
"name": "string", "name": "string",
"beep": "boolean", "beep": "boolean",
"startConferenceOnEnter": "boolean", "startConferenceOnEnter": "boolean",
@@ -175,12 +137,10 @@
}, },
"dial": { "dial": {
"properties": { "properties": {
"id": "string",
"actionHook": "object|string", "actionHook": "object|string",
"answerOnBridge": "boolean", "answerOnBridge": "boolean",
"callerId": "string", "callerId": "string",
"confirmHook": "object|string", "confirmHook": "object|string",
"referHook": "object|string",
"dialMusic": "string", "dialMusic": "string",
"dtmfCapture": "object", "dtmfCapture": "object",
"dtmfHook": "object|string", "dtmfHook": "object|string",
@@ -190,8 +150,7 @@
"timeLimit": "number", "timeLimit": "number",
"timeout": "number", "timeout": "number",
"proxy": "string", "proxy": "string",
"transcribe": "#transcribe", "transcribe": "#transcribe"
"amd": "#amd"
}, },
"required": [ "required": [
"target" "target"
@@ -199,7 +158,6 @@
}, },
"dialogflow": { "dialogflow": {
"properties": { "properties": {
"id": "string",
"credentials": "object|string", "credentials": "object|string",
"project": "string", "project": "string",
"environment": "string", "environment": "string",
@@ -228,7 +186,6 @@
}, },
"dtmf": { "dtmf": {
"properties": { "properties": {
"id": "string",
"dtmf": "string", "dtmf": "string",
"duration": "number" "duration": "number"
}, },
@@ -238,7 +195,6 @@
}, },
"lex": { "lex": {
"properties": { "properties": {
"id": "string",
"botId": "string", "botId": "string",
"botAlias": "string", "botAlias": "string",
"credentials": "object", "credentials": "object",
@@ -263,7 +219,6 @@
}, },
"listen": { "listen": {
"properties": { "properties": {
"id": "string",
"actionHook": "object|string", "actionHook": "object|string",
"auth": "#auth", "auth": "#auth",
"finishOnKey": "string", "finishOnKey": "string",
@@ -288,7 +243,6 @@
}, },
"message": { "message": {
"properties": { "properties": {
"id": "string",
"carrier": "string", "carrier": "string",
"account_sid": "string", "account_sid": "string",
"message_sid": "string", "message_sid": "string",
@@ -305,7 +259,6 @@
}, },
"pause": { "pause": {
"properties": { "properties": {
"id": "string",
"length": "number" "length": "number"
}, },
"required": [ "required": [
@@ -314,7 +267,6 @@
}, },
"rasa": { "rasa": {
"properties": { "properties": {
"id": "string",
"url": "string", "url": "string",
"recognizer": "#recognizer", "recognizer": "#recognizer",
"tts": "#synthesizer", "tts": "#synthesizer",
@@ -334,22 +286,8 @@
"path" "path"
] ]
}, },
"recordOptions": {
"properties": {
"action": {
"type": "string",
"enum": ["startCallRecording", "stopCallRecording", "pauseCallRecording", "resumeCallRecording"]
},
"recordingID": "string",
"siprecServerURL": "string"
},
"required": [
"action"
]
},
"redirect": { "redirect": {
"properties": { "properties": {
"id": "string",
"actionHook": "object|string" "actionHook": "object|string"
}, },
"required": [ "required": [
@@ -358,13 +296,11 @@
}, },
"rest:dial": { "rest:dial": {
"properties": { "properties": {
"id": "string",
"account_sid": "string", "account_sid": "string",
"application_sid": "string", "application_sid": "string",
"call_hook": "object|string", "call_hook": "object|string",
"call_status_hook": "object|string", "call_status_hook": "object|string",
"from": "string", "from": "string",
"fromHost": "string",
"speech_synthesis_vendor": "string", "speech_synthesis_vendor": "string",
"speech_synthesis_voice": "string", "speech_synthesis_voice": "string",
"speech_synthesis_language": "string", "speech_synthesis_language": "string",
@@ -383,7 +319,6 @@
}, },
"tag": { "tag": {
"properties": { "properties": {
"id": "string",
"data": "object" "data": "object"
}, },
"required": [ "required": [
@@ -392,7 +327,6 @@
}, },
"transcribe": { "transcribe": {
"properties": { "properties": {
"id": "string",
"transcriptionHook": "string", "transcriptionHook": "string",
"recognizer": "#recognizer", "recognizer": "#recognizer",
"earlyMedia": "boolean" "earlyMedia": "boolean"
@@ -413,7 +347,6 @@
"enum": ["GET", "POST"] "enum": ["GET", "POST"]
}, },
"headers": "object", "headers": "object",
"from": "#dialFrom",
"name": "string", "name": "string",
"number": "string", "number": "string",
"sipUri": "string", "sipUri": "string",
@@ -427,14 +360,6 @@
"type" "type"
] ]
}, },
"dialFrom": {
"properties": {
"user": "string",
"host": "string"
},
"required": [
]
},
"auth": { "auth": {
"properties": { "properties": {
"username": "string", "username": "string",
@@ -449,7 +374,7 @@
"properties": { "properties": {
"vendor": { "vendor": {
"type": "string", "type": "string",
"enum": ["google", "aws", "polly", "microsoft", "nuance", "ibm", "default"] "enum": ["google", "aws", "polly", "microsoft", "default"]
}, },
"language": "string", "language": "string",
"voice": "string", "voice": "string",
@@ -470,12 +395,11 @@
"properties": { "properties": {
"vendor": { "vendor": {
"type": "string", "type": "string",
"enum": ["google", "aws", "microsoft", "nuance", "deepgram", "ibm", "default"] "enum": ["google", "aws", "microsoft", "default"]
}, },
"language": "string", "language": "string",
"vad": "#vad", "vad": "#vad",
"hints": "array", "hints": "array",
"hintsBoost": "number",
"altLanguages": "array", "altLanguages": "array",
"profanityFilter": "boolean", "profanityFilter": "boolean",
"interim": "boolean", "interim": "boolean",
@@ -513,7 +437,6 @@
"tag" "tag"
] ]
}, },
"model": "string",
"outputFormat": { "outputFormat": {
"type": "string", "type": "string",
"enum": [ "enum": [
@@ -530,194 +453,12 @@
] ]
}, },
"requestSnr": "boolean", "requestSnr": "boolean",
"initialSpeechTimeoutMs": "number", "initialSpeechTimeoutMs": "number"
"azureServiceEndpoint": "string",
"azureSttEndpointId": "string",
"asrDtmfTerminationDigit": "string",
"asrTimeout": "number",
"nuanceOptions": "#nuanceOptions",
"deepgramOptions": "#deepgramOptions",
"ibmOptions": "#ibmOptions"
}, },
"required": [ "required": [
"vendor" "vendor"
] ]
}, },
"ibmOptions": {
"properties": {
"sttApiKey": "string",
"sttRegion": "string",
"ttsApiKey": "string",
"ttsRegion": "string",
"instanceId": "string",
"model": "string",
"languageCustomizationId": "string",
"acousticCustomizationId": "string",
"baseModelVersion": "string",
"watsonMetadata": "string",
"watsonLearningOptOut": "boolean"
},
"required": [
]
},
"deepgramOptions": {
"properties": {
"apiKey": "string",
"tier": {
"type": "string",
"enum": [
"enhanced",
"base"
]
},
"model": {
"type": "string",
"enum": [
"general",
"meeting",
"phonecall",
"voicemail",
"finance",
"conversationalai",
"video",
"custom"
]
},
"customModel": "string",
"version": "string",
"punctuate": "boolean",
"profanityFilter": "boolean",
"redact": {
"type": "string",
"enum": [
"pci",
"numbers",
"true",
"ssn"
]
},
"diarize": "boolean",
"diarizeVersion": "string",
"ner": "boolean",
"multichannel": "boolean",
"alternatives": "number",
"numerals": "boolean",
"search": "array",
"replace": "array",
"keywords": "array",
"endpointing": "boolean",
"vadTurnoff": "number",
"tag": "string"
}
},
"nuanceOptions": {
"properties": {
"clientId": "string",
"secret": "string",
"kryptonEndpoint": "string",
"topic": "string",
"utteranceDetectionMode": {
"type": "string",
"enum": [
"single",
"multiple",
"disabled"
]
},
"punctuation": "boolean",
"profanityFilter": "boolean",
"includeTokenization": "boolean",
"discardSpeakerAdaptation": "boolean",
"suppressCallRecording": "boolean",
"maskLoadFailures": "boolean",
"suppressInitialCapitalization": "boolean",
"allowZeroBaseLmWeight": "boolean",
"filterWakeupWord": "boolean",
"resultType": {
"type": "string",
"enum": [
"final",
"partial",
"immutable_partial"
]
},
"noInputTimeoutMs": "number",
"recognitionTimeoutMs": "number",
"utteranceEndSilenceMs": "number",
"maxHypotheses": "number",
"speechDomain": "string",
"formatting": "#formatting",
"clientData": "object",
"userId": "string",
"speechDetectionSensitivity": "number",
"resources": ["#resource"]
},
"required": [
]
},
"resource": {
"properties": {
"externalReference": "#resourceReference",
"inlineWordset": "string",
"builtin": "string",
"inlineGrammar": "string",
"wakeupWord": "[string]",
"weightName": {
"type": "string",
"enum": [
"defaultWeight",
"lowest",
"low",
"medium",
"high",
"highest"
]
},
"weightValue": "number",
"reuse": {
"type": "string",
"enum": [
"undefined_reuse",
"low_reuse",
"high_reuse"
]
}
},
"required": [
]
},
"resourceReference": {
"properties": {
"type": {
"type": "string",
"enum": [
"undefined_resource_type",
"wordset",
"compiled_wordset",
"domain_lm",
"speaker_profile",
"grammar",
"settings"
]
},
"uri": "string",
"maxLoadFailures": "boolean",
"requestTimeoutMs": "number",
"headers": "object"
},
"required": [
]
},
"formatting": {
"properties": {
"scheme": "string",
"options": "object"
},
"required": [
"scheme",
"options"
]
},
"lexIntent": { "lexIntent": {
"properties": { "properties": {
"name": "string", "name": "string",
@@ -731,29 +472,10 @@
"properties": { "properties": {
"enable": "boolean", "enable": "boolean",
"voiceMs": "number", "voiceMs": "number",
"mode": "number" "mode": "number"
}, },
"required": [ "required": [
"enable" "enable"
] ]
},
"amd": {
"properties": {
"actionHook": "object|string",
"thresholdWordCount": "number",
"timers": "#amdTimers",
"recognizer": "#recognizer"
},
"required": [
"actionHook"
]
},
"amdTimers": {
"properties": {
"noSpeechTimeoutMs": "number",
"decisionTimeoutMs": "number",
"toneTimeoutMs": "number",
"greetingCompletionTimeoutMs": "number"
}
} }
} }

View File

@@ -1,12 +1,9 @@
const Emitter = require('events'); const Emitter = require('events');
const uuidv4 = require('uuid-random'); const { v4: uuidv4 } = require('uuid');
const debug = require('debug')('jambonz:feature-server'); const debug = require('debug')('jambonz:feature-server');
const assert = require('assert'); const assert = require('assert');
const {TaskPreconditions} = require('../utils/constants'); const {TaskPreconditions} = require('../utils/constants');
const normalizeJambones = require('../utils/normalize-jambones'); const normalizeJambones = require('../utils/normalize-jambones');
const WsRequestor = require('../utils/ws-requestor');
const {TaskName} = require('../utils/constants');
const {trace} = require('@opentelemetry/api');
const specs = new Map(); const specs = new Map();
const _specData = require('./specs'); const _specData = require('./specs');
for (const key in _specData) {specs.set(key, _specData[key]);} for (const key in _specData) {specs.set(key, _specData[key]);}
@@ -23,7 +20,6 @@ class Task extends Emitter {
this.logger = logger; this.logger = logger;
this.data = data; this.data = data;
this.actionHook = this.data.actionHook; this.actionHook = this.data.actionHook;
this.id = data.id;
this._killInProgress = false; this._killInProgress = false;
this._completionPromise = new Promise((resolve) => this._completionResolver = resolve); this._completionPromise = new Promise((resolve) => this._completionResolver = resolve);
@@ -46,10 +42,6 @@ class Task extends Emitter {
return this.cs; return this.cs;
} }
get summary() {
return this.name;
}
toJSON() { toJSON() {
return this.data; return this.data;
} }
@@ -75,34 +67,6 @@ class Task extends Emitter {
setImmediate(() => this.parentTask = null); setImmediate(() => this.parentTask = null);
} }
startSpan(name, attributes) {
const {srf} = require('../..');
const {tracer} = srf.locals.otel;
const span = tracer.startSpan(name, undefined, this.ctx);
if (attributes) span.setAttributes(attributes);
trace.setSpan(this.ctx, span);
return span;
}
startChildSpan(name, attributes) {
const {srf} = require('../..');
const {tracer} = srf.locals.otel;
const span = tracer.startSpan(name, undefined, this.ctx);
if (attributes) span.setAttributes(attributes);
const ctx = trace.setSpan(this.ctx, span);
return {span, ctx};
}
getTracingPropagation(encoding, span) {
// TODO: support encodings beyond b3 https://github.com/openzipkin/b3-propagation
if (span) {
return `${span.spanContext().traceId}-${span.spanContext().spanId}-1`;
}
if (this.span) {
return `${this.span.spanContext().traceId}-${this.span.spanContext().spanId}-1`;
}
}
/** /**
* when a subclass Task has completed its work, it should call this method * when a subclass Task has completed its work, it should call this method
*/ */
@@ -140,74 +104,32 @@ class Task extends Emitter {
return this.callSession.normalizeUrl(url, method, auth); return this.callSession.normalizeUrl(url, method, auth);
} }
notifyError(obj) {
if (this.cs.requestor instanceof WsRequestor) {
const params = {...obj, verb: this.name, id: this.id};
this.cs.requestor.request('jambonz:error', '/error', params)
.catch((err) => this.logger.info({err}, 'Task:notifyError error sending error'));
}
}
notifyStatus(obj) {
if (this.cs.notifyEvents && this.cs.requestor instanceof WsRequestor) {
const params = {...obj, verb: this.name, id: this.id};
this.cs.requestor.request('verb:status', '/status', params)
.catch((err) => this.logger.info({err}, 'Task:notifyStatus error sending error'));
}
}
async performAction(results, expectResponse = true) { async performAction(results, expectResponse = true) {
if (this.actionHook) { if (this.actionHook) {
const type = this.name === TaskName.Redirect ? 'session:redirect' : 'verb:hook'; const params = results ? Object.assign(results, this.cs.callInfo.toJSON()) : this.cs.callInfo.toJSON();
const params = results ? Object.assign(this.cs.callInfo.toJSON(), results) : this.cs.callInfo.toJSON(); const json = await this.cs.requestor.request('verb:hook', this.actionHook, params);
const span = this.startSpan(type, {'hook.url': this.actionHook}); if (expectResponse && json && Array.isArray(json)) {
const b3 = this.getTracingPropagation('b3', span); const makeTask = require('./make_task');
const httpHeaders = b3 && {b3}; const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
span.setAttributes({'http.body': JSON.stringify(params)}); if (tasks && tasks.length > 0) {
try { this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
const json = await this.cs.requestor.request(type, this.actionHook, params, httpHeaders); this.callSession.replaceApplication(tasks);
span.setAttributes({'http.statusCode': 200});
span.end();
if (expectResponse && json && Array.isArray(json)) {
const makeTask = require('./make_task');
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
if (tasks && tasks.length > 0) {
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
this.callSession.replaceApplication(tasks);
}
} }
} catch (err) {
span.setAttributes({'http.statusCode': err.statusCode});
span.end();
throw err;
} }
} }
} }
async performHook(cs, hook, results) { async performHook(cs, hook, results) {
const params = results ? Object.assign(cs.callInfo.toJSON(), results) : cs.callInfo.toJSON(); const json = await cs.requestor.request('verb:hook', hook, results);
const span = this.startSpan('verb:hook', {'hook.url': hook}); if (json && Array.isArray(json)) {
const b3 = this.getTracingPropagation('b3', span); const makeTask = require('./make_task');
const httpHeaders = b3 && {b3}; const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
span.setAttributes({'http.body': JSON.stringify(params)}); if (tasks && tasks.length > 0) {
try { this.redirect(cs, tasks);
const json = await cs.requestor.request('verb:hook', hook, params, httpHeaders); return true;
span.setAttributes({'http.statusCode': 200});
span.end();
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;
} catch (err) {
span.setAttributes({'http.statusCode': err.statusCode});
span.end();
throw err;
} }
return false;
} }
redirect(cs, tasks) { redirect(cs, tasks) {
@@ -350,9 +272,6 @@ class Task extends Emitter {
} }
required = required.filter((item) => item !== dKey); required = required.filter((item) => item !== dKey);
} }
else if (dKey === '_') {
/* no op: allow arbitrary info to be carried here, used by conference e.g in transfer */
}
else throw new Error(`${name}: unknown property ${dKey}`); else throw new Error(`${name}: unknown property ${dKey}`);
} }
if (required.length > 0) throw new Error(`${name}: missing value for ${required}`); if (required.length > 0) throw new Error(`${name}: missing value for ${required}`);

View File

@@ -4,12 +4,8 @@ const {
TaskPreconditions, TaskPreconditions,
GoogleTranscriptionEvents, GoogleTranscriptionEvents,
AzureTranscriptionEvents, AzureTranscriptionEvents,
AwsTranscriptionEvents, AwsTranscriptionEvents
NuanceTranscriptionEvents,
DeepgramTranscriptionEvents,
IbmTranscriptionEvents
} = require('../utils/constants'); } = require('../utils/constants');
const normalizeJambones = require('../utils/normalize-jambones');
class TaskTranscribe extends Task { class TaskTranscribe extends Task {
constructor(logger, opts, parentTask) { constructor(logger, opts, parentTask) {
@@ -17,16 +13,6 @@ class TaskTranscribe extends Task {
this.preconditions = TaskPreconditions.Endpoint; this.preconditions = TaskPreconditions.Endpoint;
this.parentTask = parentTask; this.parentTask = parentTask;
const {
setChannelVarsForStt,
normalizeTranscription,
removeSpeechListeners,
setSpeechCredentialsAtRuntime
} = require('../utils/transcription-utils')(logger);
this.setChannelVarsForStt = setChannelVarsForStt;
this.normalizeTranscription = normalizeTranscription;
this.removeSpeechListeners = removeSpeechListeners;
this.transcriptionHook = this.data.transcriptionHook; this.transcriptionHook = this.data.transcriptionHook;
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia); this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
@@ -36,44 +22,46 @@ class TaskTranscribe extends Task {
this.interim = !!recognizer.interim; this.interim = !!recognizer.interim;
this.separateRecognitionPerChannel = recognizer.separateRecognitionPerChannel; this.separateRecognitionPerChannel = recognizer.separateRecognitionPerChannel;
/* let credentials be supplied in the recognizer object at runtime */ /* vad: if provided, we dont connect to recognizer until voice activity is detected */
this.sttCredentials = setSpeechCredentialsAtRuntime(recognizer); const {enable, voiceMs = 0, mode = -1} = recognizer.vad || {};
this.vad = {enable, voiceMs, mode};
recognizer.hints = recognizer.hints || []; /* google-specific options */
recognizer.altLanguages = recognizer.altLanguages || []; this.hints = recognizer.hints || [];
this.profanityFilter = recognizer.profanityFilter;
this.punctuation = !!recognizer.punctuation;
this.enhancedModel = !!recognizer.enhancedModel;
this.words = !!recognizer.words;
this.diarization = !!recognizer.diarization;
this.diarizationMinSpeakers = recognizer.diarizationMinSpeakers || 0;
this.diarizationMaxSpeakers = recognizer.diarizationMaxSpeakers || 0;
this.interactionType = recognizer.interactionType || 'unspecified';
this.naicsCode = recognizer.naicsCode || 0;
this.altLanguages = recognizer.altLanguages || [];
/* aws-specific options */
this.identifyChannels = !!recognizer.identifyChannels;
this.vocabularyName = recognizer.vocabularyName;
this.vocabularyFilterName = recognizer.vocabularyFilterName;
this.filterMethod = recognizer.filterMethod;
/* 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; } get name() { return TaskName.Transcribe; }
async exec(cs, {ep, ep2}) { async exec(cs, ep, parentTask) {
super.exec(cs); super.exec(cs);
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf); const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
const {getNuanceAccessToken, getIbmAccessToken} = cs.srf.locals.dbHelpers;
if (cs.hasGlobalSttHints) {
const {hints, hintsBoost} = cs.globalSttHints;
this.data.recognizer.hints = this.data.recognizer.hints.concat(hints);
if (!this.data.recognizer.hintsBoost && hintsBoost) this.data.recognizer.hintsBoost = hintsBoost;
this.logger.debug({hints: this.data.recognizer.hints, hintsBoost: this.data.recognizer.hintsBoost},
'Transcribe:exec - applying global sttHints');
}
if (cs.hasAltLanguages) {
this.data.recognizer.altLanguages = this.data.recognizer.altLanguages.concat(cs.altLanguages);
this.logger.debug({altLanguages: this.altLanguages},
'Transcribe:exec - applying altLanguages');
}
if (cs.hasGlobalSttPunctuation && !this.data.recognizer.punctuation) {
this.data.recognizer.punctuation = cs.globalSttPunctuation;
}
this.ep = ep; this.ep = ep;
this.ep2 = ep2;
if ('default' === this.vendor || !this.vendor) this.vendor = cs.speechRecognizerVendor; if ('default' === this.vendor || !this.vendor) this.vendor = cs.speechRecognizerVendor;
if ('default' === this.language || !this.language) this.language = cs.speechRecognizerLanguage; if ('default' === this.language || !this.language) this.language = cs.speechRecognizerLanguage;
if (!this.data.recognizer.vendor) { this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt');
this.data.recognizer.vendor = this.vendor;
}
if (!this.sttCredentials) this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt');
try { try {
if (!this.sttCredentials) { if (!this.sttCredentials) {
@@ -86,27 +74,7 @@ class TaskTranscribe extends Task {
}).catch((err) => this.logger.info({err}, 'Error generating alert for no stt')); }).catch((err) => this.logger.info({err}, 'Error generating alert for no stt'));
throw new Error('no provisioned speech credentials for TTS'); throw new Error('no provisioned speech credentials for TTS');
} }
await this._startTranscribing(cs, ep);
if (this.vendor === 'nuance' && this.sttCredentials.client_id) {
/* get nuance access token */
const {client_id, secret} = this.sttCredentials;
const {access_token, servedFromCache} = await getNuanceAccessToken(client_id, secret, 'asr tts');
this.logger.debug({client_id},
`Transcribe:exec - got nuance access token ${servedFromCache ? 'from cache' : ''}`);
this.sttCredentials = {...this.sttCredentials, access_token};
}
else if (this.vendor == 'ibm' && this.sttCredentials.stt_api_key) {
/* get ibm access token */
const {stt_api_key, stt_region} = this.sttCredentials;
const {access_token, servedFromCache} = await getIbmAccessToken(stt_api_key);
this.logger.debug({stt_api_key}, `Gather:exec - got ibm access token ${servedFromCache ? 'from cache' : ''}`);
this.sttCredentials = {...this.sttCredentials, access_token, stt_region};
}
await this._startTranscribing(cs, ep, 1);
if (this.separateRecognitionPerChannel && ep2) {
await this._startTranscribing(cs, ep2, 2);
}
updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid) updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid)
.catch(() => {/*already logged error */}); .catch(() => {/*already logged error */});
@@ -115,99 +83,132 @@ class TaskTranscribe extends Task {
this.logger.info(err, 'TaskTranscribe:exec - error'); this.logger.info(err, 'TaskTranscribe:exec - error');
this.parentTask && this.parentTask.emit('error', err); this.parentTask && this.parentTask.emit('error', err);
} }
this.removeSpeechListeners(ep); ep.removeCustomEventListener(GoogleTranscriptionEvents.Transcription);
ep.removeCustomEventListener(GoogleTranscriptionEvents.NoAudioDetected);
ep.removeCustomEventListener(GoogleTranscriptionEvents.MaxDurationExceeded);
ep.removeCustomEventListener(AwsTranscriptionEvents.Transcription);
ep.removeCustomEventListener(AwsTranscriptionEvents.NoAudioDetected);
ep.removeCustomEventListener(AwsTranscriptionEvents.MaxDurationExceeded);
ep.removeCustomEventListener(AzureTranscriptionEvents.Transcription);
ep.removeCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected);
} }
async kill(cs) { async kill(cs) {
super.kill(cs); super.kill(cs);
let stopTranscription = false; if (this.ep.connected) {
if (this.ep?.connected) {
stopTranscription = true;
this.ep.stopTranscription({vendor: this.vendor}) this.ep.stopTranscription({vendor: this.vendor})
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill')); .catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
}
if (this.separateRecognitionPerChannel && this.ep2 && this.ep2.connected) {
stopTranscription = true;
this.ep2.stopTranscription({vendor: this.vendor})
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
}
// hangup after 1 sec if we don't get a final transcription
if (stopTranscription) this._timer = setTimeout(() => this.notifyTaskDone(), 1500);
else this.notifyTaskDone();
// hangup after 1 sec if we don't get a final transcription
this._timer = setTimeout(() => this.notifyTaskDone(), 1000);
}
else this.notifyTaskDone();
await this.awaitTaskDone(); await this.awaitTaskDone();
} }
async _startTranscribing(cs, ep, channel) { async _startTranscribing(cs, ep) {
const opts = this.setChannelVarsForStt(this, this.sttCredentials, this.data.recognizer); const opts = {};
switch (this.vendor) {
case 'google':
this.bugname = 'google_transcribe';
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep, channel));
ep.addCustomEventListener(GoogleTranscriptionEvents.NoAudioDetected,
this._onNoAudio.bind(this, cs, ep, channel));
ep.addCustomEventListener(GoogleTranscriptionEvents.MaxDurationExceeded,
this._onMaxDurationExceeded.bind(this, cs, ep, channel));
break;
case 'aws': if (this.vad.enable) {
case 'polly': opts.START_RECOGNIZING_ON_VAD = 1;
this.bugname = 'aws_transcribe'; if (this.vad.voiceMs) opts.RECOGNIZER_VAD_VOICE_MS = this.vad.voiceMs;
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, if (this.vad.mode >= 0 && this.vad.mode <= 3) opts.RECOGNIZER_VAD_MODE = this.vad.mode;
this._onTranscription.bind(this, cs, ep, channel));
ep.addCustomEventListener(AwsTranscriptionEvents.NoAudioDetected,
this._onNoAudio.bind(this, cs, ep, channel));
ep.addCustomEventListener(AwsTranscriptionEvents.MaxDurationExceeded,
this._onMaxDurationExceeded.bind(this, cs, ep, channel));
break;
case 'microsoft':
this.bugname = 'azure_transcribe';
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep, channel));
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected,
this._onNoAudio.bind(this, cs, ep, channel));
break;
case 'nuance':
this.bugname = 'nuance_transcribe';
ep.addCustomEventListener(NuanceTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep, channel));
ep.addCustomEventListener(NuanceTranscriptionEvents.StartOfSpeech,
this._onStartOfSpeech.bind(this, cs, ep, channel));
ep.addCustomEventListener(NuanceTranscriptionEvents.TranscriptionComplete,
this._onTranscriptionComplete.bind(this, cs, ep, channel));
ep.addCustomEventListener(AzureTranscriptionEvents.Error,
this._onNuanceError.bind(this, cs, ep, channel));
break;
case 'deepgram':
this.bugname = 'deepgram_transcribe';
ep.addCustomEventListener(DeepgramTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep, channel));
ep.addCustomEventListener(DeepgramTranscriptionEvents.Connect,
this._onDeepgramConnect.bind(this, cs, ep, channel));
ep.addCustomEventListener(DeepgramTranscriptionEvents.ConnectFailure,
this._onDeepGramConnectFailure.bind(this, cs, ep, channel));
break;
case 'ibm':
this.bugname = 'ibm_transcribe';
ep.addCustomEventListener(IbmTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep, channel));
ep.addCustomEventListener(IbmTranscriptionEvents.Connect,
this._onIbmConnect.bind(this, cs, ep, channel));
ep.addCustomEventListener(IbmTranscriptionEvents.ConnectFailure,
this._onIbmConnectFailure.bind(this, cs, ep, channel));
ep.addCustomEventListener(IbmTranscriptionEvents.Error,
this._onIbmError.bind(this, cs, ep, channel));
break;
default:
throw new Error(`Invalid vendor ${this.vendor}`);
} }
await ep.set(opts) ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
.catch((err) => this.logger.info(err, 'Error setting channel variables')); ep.addCustomEventListener(GoogleTranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, cs, ep));
ep.addCustomEventListener(GoogleTranscriptionEvents.MaxDurationExceeded,
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);
[
['enhancedModel', 'GOOGLE_SPEECH_USE_ENHANCED'],
['separateRecognitionPerChannel', 'GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL'],
['profanityFilter', 'GOOGLE_SPEECH_PROFANITY_FILTER'],
['punctuation', 'GOOGLE_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION'],
['words', 'GOOGLE_SPEECH_ENABLE_WORD_TIME_OFFSETS'],
['diarization', 'GOOGLE_SPEECH_PROFANITY_FILTER']
].forEach((arr) => {
if (this[arr[0]]) opts[arr[1]] = true;
});
if (this.hints.length > 1) opts.GOOGLE_SPEECH_HINTS = this.hints.join(',');
if (this.altLanguages.length > 1) opts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
if ('unspecified' !== this.interactionType) {
opts.GOOGLE_SPEECH_METADATA_INTERACTION_TYPE = this.interactionType;
// additionally set model if appropriate
if ('phone_call' === this.interactionType) opts.GOOGLE_SPEECH_MODEL = 'phone_call';
else if (['voice_search', 'voice_command'].includes(this.interactionType)) {
opts.GOOGLE_SPEECH_MODEL = 'command_and_search';
}
else opts.GOOGLE_SPEECH_MODEL = 'phone_call';
}
else opts.GOOGLE_SPEECH_MODEL = 'phone_call';
if (this.diarization && this.diarizationMinSpeakers > 0) {
opts.GOOGLE_SPEECH_SPEAKER_DIARIZATION_MIN_SPEAKER_COUNT = this.diarizationMinSpeakers;
}
if (this.diarization && this.diarizationMaxSpeakers > 0) {
opts.GOOGLE_SPEECH_SPEAKER_DIARIZATION_MAX_SPEAKER_COUNT = this.diarizationMaxSpeakers;
}
if (this.naicsCode > 0) opts.GOOGLE_SPEECH_METADATA_INDUSTRY_NAICS_CODE = this.naicsCode;
await ep.set(opts)
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with google'));
}
else if (this.vendor === 'aws') {
[
['diarization', 'AWS_SHOW_SPEAKER_LABEL'],
['identifyChannels', 'AWS_ENABLE_CHANNEL_IDENTIFICATION']
].forEach((arr) => {
if (this[arr[0]]) opts[arr[1]] = true;
});
if (this.vocabularyName) opts.AWS_VOCABULARY_NAME = this.vocabularyName;
if (this.vocabularyFilterName) {
opts.AWS_VOCABULARY_NAME = this.vocabularyFilterName;
opts.AWS_VOCABULARY_FILTER_METHOD = this.filterMethod || 'mask';
}
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
});
}
else {
Object.assign(opts, {
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
AWS_REGION: process.env.AWS_REGION
});
}
await ep.set(opts)
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with aws'));
}
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); await this._transcribe(ep);
} }
@@ -216,54 +217,37 @@ class TaskTranscribe extends Task {
vendor: this.vendor, vendor: this.vendor,
interim: this.interim ? true : false, interim: this.interim ? true : false,
locale: this.language, locale: this.language,
channels: /*this.separateRecognitionPerChannel ? 2 : */ 1, channels: this.separateRecognitionPerChannel ? 2 : 1
bugname: this.bugname
}); });
} }
async _onTranscription(cs, ep, channel, evt, fsEvent) { _onTranscription(cs, ep, evt) {
// make sure this is not a transcript from answering machine detection this.logger.debug(evt, 'TaskTranscribe:_onTranscription');
const bugname = fsEvent.getHeader('media-bugname'); if ('aws' === this.vendor && Array.isArray(evt) && evt.length > 0) evt = evt[0];
if (bugname && this.bugname !== bugname) return; 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.logger.debug({evt}, 'TaskTranscribe:_onTranscription - before normalization'); const newEvent = {
is_final: evt.RecognitionStatus === 'Success',
evt = this.normalizeTranscription(evt, this.vendor, channel, this.language); alternatives
};
this.logger.debug({evt}, 'TaskTranscribe:_onTranscription'); evt = newEvent;
if (evt.alternatives[0]?.transcript === '' && !cs.callGone && !this.killed) {
if (['microsoft', 'deepgram'].includes(this.vendor)) {
this.logger.info({evt}, 'TaskTranscribe:_onTranscription - got empty transcript, continue listening');
}
else {
this.logger.info({evt}, 'TaskTranscribe:_onTranscription - got empty transcript, listen again');
this._transcribe(ep);
}
return;
} }
if (this.transcriptionHook) { if (this.transcriptionHook) {
const b3 = this.getTracingPropagation(); this.cs.requestor.request('verb:hook', this.transcriptionHook, Object.assign({speech: evt}, this.cs.callInfo))
const httpHeaders = b3 && {b3}; .catch((err) => this.logger.info(err, 'TranscribeTask:_onTranscription error'));
try {
const json = await this.cs.requestor.request('verb:hook', this.transcriptionHook, {
...this.cs.callInfo,
...httpHeaders,
speech: evt
});
this.logger.info({json}, 'sent transcriptionHook');
if (json && Array.isArray(json) && !this.parentTask) {
const makeTask = require('./make_task');
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
if (tasks && tasks.length > 0) {
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
this.cs.replaceApplication(tasks);
}
}
} catch (err) {
this.logger.info(err, 'TranscribeTask:_onTranscription error');
}
} }
if (this.parentTask) { if (this.parentTask) {
this.parentTask.emit('transcription', evt); this.parentTask.emit('transcription', evt);
@@ -275,13 +259,13 @@ class TaskTranscribe extends Task {
} }
} }
_onNoAudio(cs, ep, channel) { _onNoAudio(cs, ep) {
this.logger.debug(`TaskTranscribe:_onNoAudio restarting transcription on channel ${channel}`); this.logger.debug('TaskTranscribe:_onNoAudio restarting transcription');
this._transcribe(ep); this._transcribe(ep);
} }
_onMaxDurationExceeded(cs, ep, channel) { _onMaxDurationExceeded(cs, ep) {
this.logger.debug(`TaskTranscribe:_onMaxDurationExceeded restarting transcription on channel ${channel}`); this.logger.debug('TaskTranscribe:_onMaxDurationExceeded restarting transcription');
this._transcribe(ep); this._transcribe(ep);
} }
@@ -291,57 +275,6 @@ class TaskTranscribe extends Task {
this._timer = null; this._timer = null;
} }
} }
_onNuanceError(_cs, _ep, _channel, evt) {
const {code, error, details} = evt;
if (code === 404 && error === 'No speech') {
this.logger.debug({code, error, details}, 'TaskTranscribe:_onNuanceError');
return this._resolve('timeout');
}
this.logger.info({code, error, details}, 'TaskTranscribe:_onNuanceError');
if (code === 413 && error === 'Too much speech') {
return this._resolve('timeout');
}
}
_onDeepgramConnect(_cs, _ep) {
this.logger.debug('TaskTranscribe:_onDeepgramConnect');
}
_onDeepGramConnectFailure(cs, _ep, _channel, evt) {
const {reason} = evt;
const {writeAlerts, AlertType} = cs.srf.locals;
this.logger.info({evt}, 'TaskTranscribe:_onDeepgramConnectFailure');
writeAlerts({
account_sid: cs.accountSid,
alert_type: AlertType.STT_FAILURE,
message: `Failed connecting to Deepgram speech recognizer: ${reason}`,
vendor: 'deepgram',
}).catch((err) => this.logger.info({err}, 'Error generating alert for deepgram connection failure'));
this.notifyError(`Failed connecting to speech vendor deepgram: ${reason}`);
this.notifyTaskDone();
}
_onIbmConnect(_cs, _ep) {
this.logger.debug('TaskTranscribe:_onIbmConnect');
}
_onIbmConnectFailure(cs, _ep, _channel, evt) {
const {reason} = evt;
const {writeAlerts, AlertType} = cs.srf.locals;
this.logger.info({evt}, 'TaskTranscribe:_onIbmConnectFailure');
writeAlerts({
account_sid: cs.accountSid,
alert_type: AlertType.STT_FAILURE,
message: `Failed connecting to IBM watson speech recognizer: ${reason}`,
vendor: 'ibm',
}).catch((err) => this.logger.info({err}, 'Error generating alert for IBM connection failure'));
this.notifyError(`Failed connecting to speech vendor IBM: ${reason}`);
this.notifyTaskDone();
}
_onIbmError(cs, _ep, _channel, evt) {
this.logger.info({evt}, 'TaskGather:_onIbmError');
}
} }
module.exports = TaskTranscribe; module.exports = TaskTranscribe;

View File

@@ -1,344 +0,0 @@
const Emitter = require('events');
const {readFile} = require('fs');
const {
GoogleTranscriptionEvents,
AwsTranscriptionEvents,
AzureTranscriptionEvents,
AmdEvents,
AvmdEvents
} = require('./constants');
const bugname = 'amd_bug';
const {VMD_HINTS_FILE} = process.env;
let voicemailHints = [];
const updateHints = async(file, callback) => {
readFile(file, 'utf8', (err, data) => {
if (err) return callback(err);
try {
callback(null, JSON.parse(data));
} catch (err) {
callback(err);
}
});
};
if (VMD_HINTS_FILE) {
updateHints(VMD_HINTS_FILE, (err, hints) => {
if (err) { console.error(err); }
voicemailHints = hints;
/* if successful, update the hints every hour */
setInterval(() => {
updateHints(VMD_HINTS_FILE, (err, hints) => {
if (err) { console.error(err); }
voicemailHints = hints;
});
}, 60000);
});
}
class Amd extends Emitter {
constructor(logger, cs, opts) {
super();
this.logger = logger;
this.vendor = opts.recognizer?.vendor || cs.speechRecognizerVendor;
if ('default' === this.vendor) this.vendor = cs.speechRecognizerVendor;
this.language = opts.recognizer?.language || cs.speechRecognizerLanguage;
if ('default' === this.language) this.language = cs.speechRecognizerLanguage;
this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt');
if (!this.sttCredentials) throw new Error(`No speech credentials found for vendor ${this.vendor}`);
this.thresholdWordCount = opts.thresholdWordCount || 9;
const {normalizeTranscription} = require('./transcription-utils')(logger);
this.normalizeTranscription = normalizeTranscription;
const {
noSpeechTimeoutMs = 5000,
decisionTimeoutMs = 15000,
toneTimeoutMs = 20000,
greetingCompletionTimeoutMs = 2000
} = opts.timers || {};
this.noSpeechTimeoutMs = noSpeechTimeoutMs;
this.decisionTimeoutMs = decisionTimeoutMs;
this.toneTimeoutMs = toneTimeoutMs;
this.greetingCompletionTimeoutMs = greetingCompletionTimeoutMs;
this.beepDetected = false;
}
startDecisionTimer() {
this.decisionTimer = setTimeout(this._onDecisionTimeout.bind(this), this.decisionTimeoutMs);
this.noSpeechTimer = setTimeout(this._onNoSpeechTimeout.bind(this), this.noSpeechTimeoutMs);
this.startToneTimer();
}
stopDecisionTimer() {
this.decisionTimer && clearTimeout(this.decisionTimer);
}
stopNoSpeechTimer() {
this.noSpeechTimer && clearTimeout(this.noSpeechTimer);
}
startToneTimer() {
this.toneTimer = setTimeout(this._onToneTimeout.bind(this), this.toneTimeoutMs);
}
startGreetingCompletionTimer() {
this.greetingCompletionTimer = setTimeout(
this._onGreetingCompletionTimeout.bind(this),
this.beepDetected ? 1000 : this.greetingCompletionTimeoutMs);
}
stopGreetingCompletionTimer() {
this.greetingCompletionTimer && clearTimeout(this.greetingCompletionTimer);
}
restartGreetingCompletionTimer() {
this.stopGreetingCompletionTimer();
this.startGreetingCompletionTimer();
}
stopToneTimer() {
this.toneTimer && clearTimeout(this.toneTimer);
}
stopAllTimers() {
this.stopDecisionTimer();
this.stopNoSpeechTimer();
this.stopToneTimer();
this.stopGreetingCompletionTimer();
}
_onDecisionTimeout() {
this.emit(this.decision = AmdEvents.DecisionTimeout);
this.stopNoSpeechTimer();
}
_onToneTimeout() {
this.emit(AmdEvents.ToneTimeout);
}
_onNoSpeechTimeout() {
this.emit(this.decision = AmdEvents.NoSpeechDetected);
this.stopDecisionTimer();
}
_onGreetingCompletionTimeout() {
this.emit(AmdEvents.MachineStoppedSpeaking);
}
evaluateTranscription(evt) {
if (this.decision) {
/* at this point we are only listening for the machine to stop speaking */
if (this.decision === AmdEvents.MachineDetected) {
this.restartGreetingCompletionTimer();
}
return;
}
this.stopNoSpeechTimer();
this.logger.debug({evt}, 'Amd:evaluateTranscription - raw');
const t = this.normalizeTranscription(evt, this.vendor, this.language);
const hints = voicemailHints[this.language] || [];
this.logger.debug({t}, 'Amd:evaluateTranscription - normalized');
if (Array.isArray(t.alternatives) && t.alternatives.length > 0) {
const wordCount = t.alternatives[0].transcript.split(' ').length;
const final = t.is_final;
const foundHint = hints.find((h) => t.alternatives[0].transcript.includes(h));
if (foundHint) {
/* we detected a common voice mail greeting */
this.logger.debug(`Amd:evaluateTranscription: found hint ${foundHint}`);
this.emit(this.decision = AmdEvents.MachineDetected, {
reason: 'hint',
hint: foundHint,
language: t.language_code
});
}
else if (final && wordCount < this.thresholdWordCount) {
/* a short greeting is typically a human */
this.emit(this.decision = AmdEvents.HumanDetected, {
reason: 'short greeting',
greeting: t.alternatives[0].transcript,
language: t.language_code
});
}
else if (wordCount >= this.thresholdWordCount) {
/* a long greeting is typically a machine */
this.emit(this.decision = AmdEvents.MachineDetected, {
reason: 'long greeting',
greeting: t.alternatives[0].transcript,
language: t.language_code
});
}
if (this.decision) {
this.stopDecisionTimer();
if (this.decision === AmdEvents.MachineDetected) {
/* if we detected a machine, then wait for greeting to end */
this.startGreetingCompletionTimer();
}
}
return this.decision;
}
}
}
module.exports = (logger) => {
const startTranscribing = async(cs, ep, task) => {
const {vendor, language} = ep.amd;
ep.startTranscription({
vendor,
language,
interim: true,
bugname
}).catch((err) => {
const {writeAlerts, AlertType} = cs.srf.locals;
ep.amd = null;
task.emit(AmdEvents.Error, err);
logger.error(err, 'amd:_startTranscribing error');
writeAlerts({
account_sid: cs.accountSid,
alert_type: AlertType.STT_FAILURE,
vendor: vendor,
detail: err.message
});
}).catch((err) => logger.info({err}, 'Error generating alert for tts failure'));
};
const onEndOfUtterance = (cs, ep, task) => {
logger.debug('amd:onEndOfUtterance');
startTranscribing(cs, ep, task);
};
const onNoSpeechDetected = (cs, ep, task) => {
logger.debug('amd:onNoSpeechDetected');
ep.amd.stopAllTimers();
task.emit(AmdEvents.NoSpeechDetected);
};
const onTranscription = (cs, ep, task, evt, fsEvent) => {
if (fsEvent.getHeader('media-bugname') !== bugname) return;
ep.amd?.evaluateTranscription(evt);
};
const onBeep = (cs, ep, task, evt, fsEvent) => {
logger.debug({evt, fsEvent}, 'onBeep');
const frequency = Math.floor(fsEvent.getHeader('Frequency'));
const variance = Math.floor(fsEvent.getHeader('Frequency-variance'));
task.emit('amd', {type: AmdEvents.ToneDetected, frequency, variance});
if (ep.amd) {
ep.amd.stopToneTimer();
ep.amd.beepDetected = true;
}
ep.execute('avmd_stop').catch((err) => this.logger.info(err, 'Error stopping avmd'));
};
const startAmd = async(cs, ep, task, opts) => {
const amd = ep.amd = new Amd(logger, cs, opts);
const {vendor, language, sttCredentials} = amd;
const sttOpts = {};
const hints = voicemailHints[language] || [];
/* set stt options */
logger.info(`starting amd for vendor ${vendor} and language ${language}`);
if ('google' === vendor) {
sttOpts.GOOGLE_APPLICATION_CREDENTIALS = JSON.stringify(sttCredentials.credentials);
sttOpts.GOOGLE_SPEECH_USE_ENHANCED = true;
sttOpts.GOOGLE_SPEECH_HINTS = hints.join(',');
if (opts.recognizer?.altLanguages) {
sttOpts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = opts.recognizer.altLanguages.join(',');
}
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, onTranscription.bind(null, cs, ep, task));
ep.addCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance, onEndOfUtterance.bind(null, cs, ep, task));
}
else if (['aws', 'polly'].includes(vendor)) {
Object.assign(sttOpts, {
AWS_ACCESS_KEY_ID: sttCredentials.accessKeyId,
AWS_SECRET_ACCESS_KEY: sttCredentials.secretAccessKey,
AWS_REGION: sttCredentials.region
});
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, onTranscription.bind(null, cs, ep, task));
}
else if ('microsoft' === vendor) {
Object.assign(sttOpts, {
'AZURE_SUBSCRIPTION_KEY': sttCredentials.api_key,
'AZURE_REGION': sttCredentials.region
});
sttOpts.AZURE_SPEECH_HINTS = hints.join(',');
if (opts.recognizer?.altLanguages) {
sttOpts.AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = opts.recognizer.altLanguages.join(',');
}
sttOpts.AZURE_INITIAL_SPEECH_TIMEOUT_MS = opts.resolveTimeoutMs || 20000;
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription, onTranscription.bind(null, cs, ep, task));
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected, onNoSpeechDetected.bind(null, cs, ep, task));
}
logger.debug({sttOpts}, 'startAmd: setting channel vars');
await ep.set(sttOpts).catch((err) => logger.info(err, 'Error setting channel variables'));
amd
.on(AmdEvents.NoSpeechDetected, (evt) => {
task.emit('amd', {type: AmdEvents.NoSpeechDetected, ...evt});
try {
ep.connected && ep.stopTranscription({vendor, bugname});
} catch (err) {
logger.info({err}, 'Error stopping transcription');
}
})
.on(AmdEvents.HumanDetected, (evt) => {
task.emit('amd', {type: AmdEvents.HumanDetected, ...evt});
try {
ep.connected && ep.stopTranscription({vendor, bugname});
} catch (err) {
logger.info({err}, 'Error stopping transcription');
}
})
.on(AmdEvents.MachineDetected, (evt) => {
task.emit('amd', {type: AmdEvents.MachineDetected, ...evt});
})
.on(AmdEvents.DecisionTimeout, (evt) => {
task.emit('amd', {type: AmdEvents.DecisionTimeout, ...evt});
try {
ep.connected && ep.stopTranscription({vendor, bugname});
} catch (err) {
logger.info({err}, 'Error stopping transcription');
}
})
.on(AmdEvents.ToneTimeout, (evt) => {
//task.emit('amd', {type: AmdEvents.ToneTimeout, ...evt});
try {
ep.connected && ep.execute('avmd_stop').catch((err) => logger.info(err, 'Error stopping avmd'));
} catch (err) {
logger.info({err}, 'Error stopping avmd');
}
})
.on(AmdEvents.MachineStoppedSpeaking, () => {
task.emit('amd', {type: AmdEvents.MachineStoppedSpeaking});
try {
ep.connected && ep.stopTranscription({vendor, bugname});
} catch (err) {
logger.info({err}, 'Error stopping transcription');
}
});
/* start transcribing, and also listening for beep */
amd.startDecisionTimer();
startTranscribing(cs, ep, task);
ep.addCustomEventListener(AvmdEvents.Beep, onBeep.bind(null, cs, ep, task));
ep.execute('avmd_start').catch((err) => this.logger.info(err, 'Error starting avmd'));
};
const stopAmd = (ep, task) => {
let vendor;
if (ep.amd) {
vendor = ep.amd.vendor;
ep.amd.stopAllTimers();
ep.amd = null;
}
if (ep.connected) {
ep.stopTranscription({vendor, bugname})
.catch((err) => logger.info(err, 'stopAmd: Error stopping transcription'));
task.emit('amd', {type: AmdEvents.Stopped});
ep.execute('avmd_stop').catch((err) => this.logger.info(err, 'Error stopping avmd'));
}
ep.removeCustomEventListener(AvmdEvents.Beep);
};
return {startAmd, stopAmd};
};

View File

@@ -1,7 +1,7 @@
const Emitter = require('events'); const Emitter = require('events');
const bent = require('bent'); const bent = require('bent');
const assert = require('assert'); const assert = require('assert');
const PORT = process.env.AWS_SNS_PORT || 3010; const PORT = process.env.AWS_SNS_PORT || 3001;
const {LifeCycleEvents} = require('./constants'); const {LifeCycleEvents} = require('./constants');
const express = require('express'); const express = require('express');
const app = express(); const app = express();
@@ -21,26 +21,6 @@ class SnsNotifier extends Emitter {
this.logger = logger; this.logger = logger;
} }
_doListen(logger, app, port, resolve) {
return app.listen(port, () => {
this.snsEndpoint = `http://${this.publicIp}:${port}`;
logger.info(`SNS lifecycle server listening on http://localhost:${port}`);
resolve(app);
});
}
_handleErrors(logger, app, resolve, reject, e) {
if (e.code === 'EADDRINUSE' &&
process.env.AWS_SNS_PORT_MAX &&
e.port < process.env.AWS_SNS_PORT_MAX) {
logger.info(`SNS lifecycle server failed to bind port on ${e.port}, will try next port`);
const server = this._doListen(logger, app, ++e.port, resolve);
server.on('error', this._handleErrors.bind(this, logger, app, resolve, reject));
return;
}
reject(e);
}
async _handlePost(req, res) { async _handlePost(req, res) {
try { try {
@@ -104,9 +84,11 @@ class SnsNotifier extends Emitter {
this.logger.debug('SnsNotifier: retrieving instance data'); this.logger.debug('SnsNotifier: retrieving instance data');
this.instanceId = await getString('http://169.254.169.254/latest/meta-data/instance-id'); this.instanceId = await getString('http://169.254.169.254/latest/meta-data/instance-id');
this.publicIp = await getString('http://169.254.169.254/latest/meta-data/public-ipv4'); this.publicIp = await getString('http://169.254.169.254/latest/meta-data/public-ipv4');
this.snsEndpoint = `http://${this.publicIp}:${PORT}`;
this.logger.info({ this.logger.info({
instanceId: this.instanceId, instanceId: this.instanceId,
publicIp: this.publicIp publicIp: this.publicIp,
snsEndpoint: this.snsEndpoint
}, 'retrieved AWS instance data'); }, 'retrieved AWS instance data');
// start listening // start listening
@@ -118,10 +100,7 @@ class SnsNotifier extends Emitter {
this.logger.error(err, 'burped error'); this.logger.error(err, 'burped error');
res.status(err.status || 500).json({msg: err.message}); res.status(err.status || 500).json({msg: err.message});
}); });
return new Promise((resolve, reject) => { app.listen(PORT);
const server = this._doListen(this.logger, app, PORT, resolve);
server.on('error', this._handleErrors.bind(this, this.logger, app, resolve, reject));
});
} catch (err) { } catch (err) {
this.logger.error({err}, 'Error retrieving AWS instance metadata'); this.logger.error({err}, 'Error retrieving AWS instance metadata');

View File

@@ -1,78 +0,0 @@
const {context, trace} = require('@opentelemetry/api');
const {Dialog} = require('drachtio-srf');
class RootSpan {
constructor(callType, req) {
let tracer, callSid, linkedSpanId;
if (req instanceof Dialog) {
const dlg = req;
tracer = dlg.srf.locals.otel.tracer;
callSid = dlg.callSid;
linkedSpanId = dlg.linkedSpanId;
}
else {
tracer = req.srf.locals.otel.tracer;
callSid = req.locals.callSid;
}
this._span = tracer.startSpan(callType || 'incoming-call');
if (req instanceof Dialog) {
const dlg = req;
this._span.setAttributes({
linkedSpanId,
callId: dlg.sip.callId
});
}
else {
this._span.setAttributes({
callSid,
accountSid: req.get('X-Account-Sid'),
applicationSid: req.locals.application_sid,
callId: req.get('Call-ID'),
externalCallId: req.get('X-CID')
});
}
this._ctx = trace.setSpan(context.active(), this._span);
this.tracer = tracer;
}
get context() {
return this._ctx;
}
get traceId() {
return this._span.spanContext().traceId;
}
get spanId() {
return this._span.spanContext().spanId;
}
get traceFlags() {
return this._span.spanContext().traceFlags;
}
getTracingPropagation(encoding) {
// TODO: support encodings beyond b3 https://github.com/openzipkin/b3-propagation
if (this._span && this.traceId !== '00000000000000000000000000000000') {
return `${this.traceId}-${this.spanId}-1`;
}
}
setAttributes(attrs) {
this._span.setAttributes(attrs);
}
end() {
this._span.end();
}
startChildSpan(name, attributes) {
const span = this.tracer.startSpan(name, attributes, this._ctx);
const ctx = trace.setSpan(context.active(), span);
return {span, ctx};
}
}
module.exports = RootSpan;

View File

@@ -2,7 +2,6 @@
"TaskName": { "TaskName": {
"Cognigy": "cognigy", "Cognigy": "cognigy",
"Conference": "conference", "Conference": "conference",
"Config": "config",
"Dequeue": "dequeue", "Dequeue": "dequeue",
"Dial": "dial", "Dial": "dial",
"Dialogflow": "dialogflow", "Dialogflow": "dialogflow",
@@ -20,7 +19,6 @@
"Redirect": "redirect", "Redirect": "redirect",
"RestDial": "rest:dial", "RestDial": "rest:dial",
"SipDecline": "sip:decline", "SipDecline": "sip:decline",
"SipRequest": "sip:request",
"SipRefer": "sip:refer", "SipRefer": "sip:refer",
"SipNotify": "sip:notify", "SipNotify": "sip:notify",
"SipRedirect": "sip:redirect", "SipRedirect": "sip:redirect",
@@ -29,7 +27,6 @@
"Tag": "tag", "Tag": "tag",
"Transcribe": "transcribe" "Transcribe": "transcribe"
}, },
"AllowedSipRecVerbs": ["config", "gather", "transcribe", "listen"],
"CallStatus": { "CallStatus": {
"Trying": "trying", "Trying": "trying",
"Ringing": "ringing", "Ringing": "ringing",
@@ -57,47 +54,23 @@
"StableCall": "stable-call", "StableCall": "stable-call",
"UnansweredCall": "unanswered-call" "UnansweredCall": "unanswered-call"
}, },
"AvmdEvents": {
"Beep": "avmd::beep"
},
"GoogleTranscriptionEvents": { "GoogleTranscriptionEvents": {
"Transcription": "google_transcribe::transcription", "Transcription": "google_transcribe::transcription",
"EndOfUtterance": "google_transcribe::end_of_utterance", "EndOfUtterance": "google_transcribe::end_of_utterance",
"NoAudioDetected": "google_transcribe::no_audio_detected", "NoAudioDetected": "google_transcribe::no_audio_detected",
"MaxDurationExceeded": "google_transcribe::max_duration_exceeded", "MaxDurationExceeded": "google_transcribe::max_duration_exceeded"
"VadDetected": "google_transcribe::vad_detected"
},
"NuanceTranscriptionEvents": {
"Transcription": "nuance_transcribe::transcription",
"StartOfSpeech": "nuance_transcribe::start_of_speech",
"TranscriptionComplete": "nuance_transcribe::end_of_transcription",
"Error": "nuance_transcribe::error",
"VadDetected": "nuance_transcribe::vad_detected"
},
"DeepgramTranscriptionEvents": {
"Transcription": "deepgram_transcribe::transcription",
"ConnectFailure": "deepgram_transcribe::connect_failed",
"Connect": "deepgram_transcribe::connect"
},
"IbmTranscriptionEvents": {
"Transcription": "ibm_transcribe::transcription",
"ConnectFailure": "ibm_transcribe::connect_failed",
"Connect": "ibm_transcribe::connect",
"Error": "ibm_transcribe::error"
}, },
"AwsTranscriptionEvents": { "AwsTranscriptionEvents": {
"Transcription": "aws_transcribe::transcription", "Transcription": "aws_transcribe::transcription",
"EndOfTranscript": "aws_transcribe::end_of_transcript", "EndOfTranscript": "aws_transcribe::end_of_transcript",
"NoAudioDetected": "aws_transcribe::no_audio_detected", "NoAudioDetected": "aws_transcribe::no_audio_detected",
"MaxDurationExceeded": "aws_transcribe::max_duration_exceeded", "MaxDurationExceeded": "aws_transcribe::max_duration_exceeded"
"VadDetected": "aws_transcribe::vad_detected"
}, },
"AzureTranscriptionEvents": { "AzureTranscriptionEvents": {
"Transcription": "azure_transcribe::transcription", "Transcription": "azure_transcribe::transcription",
"StartOfUtterance": "azure_transcribe::start_of_utterance", "StartOfUtterance": "azure_transcribe::start_of_utterance",
"EndOfUtterance": "azure_transcribe::end_of_utterance", "EndOfUtterance": "azure_transcribe::end_of_utterance",
"NoSpeechDetected": "azure_transcribe::no_speech_detected", "NoSpeechDetected": "azure_transcribe::no_speech_detected"
"VadDetected": "azure_transcribe::vad_detected"
}, },
"ListenEvents": { "ListenEvents": {
"Connect": "mod_audio_fork::connect", "Connect": "mod_audio_fork::connect",
@@ -138,26 +111,9 @@
"session:redirect", "session:redirect",
"call:status", "call:status",
"queue:status", "queue:status",
"dial:confirm",
"verb:hook", "verb:hook",
"jambonz:error" "jambonz:error"
], ],
"RecordState": {
"RecordingOn": "recording_on",
"RecordingOff": "recording_off",
"RecordingPaused": "recording_paused"
},
"AmdEvents": {
"NoSpeechDetected": "amd_no_speech_detected",
"HumanDetected": "amd_human_detected",
"MachineDetected": "amd_machine_detected",
"MachineStoppedSpeaking": "amd_machine_stopped_speaking",
"Error": "amd_error",
"DecisionTimeout": "amd_decision_timeout",
"ToneDetected": "amd_tone_detected",
"ToneTimeout": "amd_tone_timeout",
"Stopped": "amd_stopped"
},
"MAX_SIMRINGS": 10, "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" "FS_UUID_SET_NAME": "fsUUIDs"

View File

@@ -1,52 +0,0 @@
const {execSync} = require('child_process');
const now = Date.now();
const fsInventory = process.env.JAMBONES_FREESWITCH
.split(',')
.map((fs) => {
const arr = /^([^:]*):([^:]*):([^:]*)(?::([^:]*))?/.exec(fs);
const opts = {address: arr[1], port: arr[2], secret: arr[3]};
if (arr.length > 4) opts.advertisedAddress = arr[4];
if (process.env.NODE_ENV === 'test') opts.listenAddress = '0.0.0.0';
return opts;
});
const clearChannels = () => {
const {logger} = require('../..');
const pwd = fsInventory[0].secret;
const maxDurationMins = process.env.JAMBONES_FREESWITCH_MAX_CALL_DURATION_MINS || 180;
const calls = execSync(`/usr/local/freeswitch/bin/fs_cli -p ${pwd} -x "show calls"`, {encoding: 'utf8'})
.split('\n')
.filter((line) => line.match(/^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{8}/))
.map((line) => {
const arr = line.split(',');
const dt = new Date(arr[2]);
const duration = (now - dt.getTime()) / 1000;
return {
uuid: arr[0],
time: arr[2],
duration
};
})
.filter((c) => c.duration > 60 * maxDurationMins);
if (calls.length > 0) {
logger.debug(`clearChannels: clearing ${calls.length} old calls longer than ${maxDurationMins} mins`);
for (const call of calls) {
const cmd = `/usr/local/freeswitch/bin/fs_cli -p ${pwd} -x "uuid_kill ${call.uuid}"`;
const out = execSync(cmd, {encoding: 'utf8'});
logger.debug({out}, 'clearChannels: command output');
}
}
return calls.length;
};
const clearFiles = () => {
//const {logger} = require('../..');
/*const out = */ execSync('find /tmp -name "*.mp3" -mtime +2 -exec rm {} \\;');
//logger.debug({out}, 'clearFiles: command output');
};
module.exports = {clearChannels, clearFiles};

View File

@@ -23,47 +23,22 @@ AND vc.name = ?`;
const speechMapper = (cred) => { const speechMapper = (cred) => {
const {credential, ...obj} = cred; const {credential, ...obj} = cred;
try { if ('google' === obj.vendor) {
if ('google' === obj.vendor) { obj.service_key = decrypt(credential);
obj.service_key = decrypt(credential); }
} else if ('aws' === obj.vendor) {
else if ('aws' === obj.vendor) { const o = JSON.parse(decrypt(credential));
const o = JSON.parse(decrypt(credential)); obj.access_key_id = o.access_key_id;
obj.access_key_id = o.access_key_id; obj.secret_access_key = o.secret_access_key;
obj.secret_access_key = o.secret_access_key; }
obj.aws_region = o.aws_region; else if ('microsoft' === obj.vendor) {
} const o = JSON.parse(decrypt(credential));
else if ('microsoft' === obj.vendor) { obj.api_key = o.api_key;
const o = JSON.parse(decrypt(credential)); obj.region = o.region;
obj.api_key = o.api_key; }
obj.region = o.region; else if ('wellsaid' === obj.vendor) {
obj.use_custom_stt = o.use_custom_stt; const o = JSON.parse(decrypt(credential));
obj.custom_stt_endpoint = o.custom_stt_endpoint; obj.api_key = o.api_key;
obj.use_custom_tts = o.use_custom_tts;
obj.custom_tts_endpoint = o.custom_tts_endpoint;
}
else if ('wellsaid' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
}
else if ('nuance' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.client_id = o.client_id;
obj.secret = o.secret;
}
else if ('ibm' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.tts_api_key = o.tts_api_key;
obj.tts_region = o.tts_region;
obj.stt_api_key = o.stt_api_key;
obj.stt_region = o.stt_region;
}
else if ('deepgram' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
}
} catch (err) {
console.log(err);
} }
return obj; return obj;
}; };
@@ -73,7 +48,6 @@ module.exports = (logger, srf) => {
const pp = pool.promise(); const pp = pool.promise();
const lookupAccountDetails = async(account_sid) => { const lookupAccountDetails = async(account_sid) => {
const [r] = await pp.query({sql: sqlAccountDetails, nestTables: true}, account_sid); const [r] = await pp.query({sql: sqlAccountDetails, nestTables: true}, account_sid);
if (0 === r.length) throw new Error(`invalid accountSid: ${account_sid}`); if (0 === r.length) throw new Error(`invalid accountSid: ${account_sid}`);
const [r2] = await pp.query(sqlSpeechCredentials, account_sid); const [r2] = await pp.query(sqlSpeechCredentials, account_sid);
@@ -84,10 +58,7 @@ module.exports = (logger, srf) => {
const haveAws = speech.find((s) => s.vendor === 'aws'); const haveAws = speech.find((s) => s.vendor === 'aws');
const haveMicrosoft = speech.find((s) => s.vendor === 'microsoft'); const haveMicrosoft = speech.find((s) => s.vendor === 'microsoft');
const haveWellsaid = speech.find((s) => s.vendor === 'wellsaid'); const haveWellsaid = speech.find((s) => s.vendor === 'wellsaid');
const haveNuance = speech.find((s) => s.vendor === 'nuance'); if (!haveGoogle || !haveAws || !haveMicrosoft) {
const haveDeepgram = speech.find((s) => s.vendor === 'deepgram');
const haveIbm = speech.find((s) => s.vendor === 'ibm');
if (!haveGoogle || !haveAws || !haveMicrosoft || !haveWellsaid || !haveNuance) {
const [r3] = await pp.query(sqlSpeechCredentialsForSP, account_sid); const [r3] = await pp.query(sqlSpeechCredentialsForSP, account_sid);
if (r3.length) { if (r3.length) {
if (!haveGoogle) { if (!haveGoogle) {
@@ -106,18 +77,6 @@ module.exports = (logger, srf) => {
const wellsaid = r3.find((s) => s.vendor === 'wellsaid'); const wellsaid = r3.find((s) => s.vendor === 'wellsaid');
if (wellsaid) speech.push(speechMapper(wellsaid)); if (wellsaid) speech.push(speechMapper(wellsaid));
} }
if (!haveNuance) {
const nuance = r3.find((s) => s.vendor === 'nuance');
if (nuance) speech.push(speechMapper(nuance));
}
if (!haveDeepgram) {
const deepgram = r3.find((s) => s.vendor === 'deepgram');
if (deepgram) speech.push(speechMapper(deepgram));
}
if (!haveIbm) {
const ibm = r3.find((s) => s.vendor === 'ibm');
if (ibm) speech.push(speechMapper(ibm));
}
} }
} }
@@ -128,7 +87,6 @@ module.exports = (logger, srf) => {
}; };
const updateSpeechCredentialLastUsed = async(speech_credential_sid) => { const updateSpeechCredentialLastUsed = async(speech_credential_sid) => {
if (!speech_credential_sid) return;
const pp = pool.promise(); const pp = pool.promise();
const sql = 'UPDATE speech_credentials SET last_used = NOW() WHERE speech_credential_sid = ?'; const sql = 'UPDATE speech_credentials SET last_used = NOW() WHERE speech_credential_sid = ?';
try { try {

View File

@@ -1,45 +0,0 @@
const express = require('express');
const httpRoutes = require('../http-routes');
const PORT = process.env.HTTP_PORT || 3000;
const doListen = (logger, app, port, resolve) => {
const server = app.listen(port, () => {
const {srf} = app.locals;
logger.info(`listening for HTTP requests on port ${PORT}, serviceUrl is ${srf.locals.serviceUrl}`);
resolve({server, app});
});
return server;
};
const handleErrors = (logger, app, resolve, reject, e) => {
if (e.code === 'EADDRINUSE' &&
process.env.HTTP_PORT_MAX &&
e.port < process.env.HTTP_PORT_MAX) {
logger.info(`HTTP server failed to bind port on ${e.port}, will try next port`);
const server = doListen(logger, app, ++e.port, resolve);
server.on('error', handleErrors.bind(null, logger, app, resolve, reject));
return;
}
logger.info({err: e, port: PORT}, 'httpListener error');
reject(e);
};
const createHttpListener = (logger, srf) => {
const app = express();
app.locals = {...app.locals, logger, srf};
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use('/', httpRoutes);
app.use((err, _req, res, _next) => {
logger.error(err, 'burped error');
res.status(err.status || 500).json({msg: err.message});
});
return new Promise((resolve, reject) => {
const server = doListen(logger, app, PORT, resolve);
server.on('error', handleErrors.bind(null, logger, app, resolve, reject));
});
};
module.exports = createHttpListener;

View File

@@ -1,12 +1,9 @@
const {Client, Pool} = require('undici'); const bent = require('bent');
const parseUrl = require('parse-url'); const parseUrl = require('parse-url');
const assert = require('assert'); const assert = require('assert');
const BaseRequestor = require('./base-requestor'); const BaseRequestor = require('./base-requestor');
const WsRequestor = require('./ws-requestor');
const {HookMsgTypes} = require('./constants.json'); const {HookMsgTypes} = require('./constants.json');
const snakeCaseKeys = require('./snakecase-keys'); const snakeCaseKeys = require('./snakecase-keys');
const pools = new Map();
const HTTP_TIMEOUT = 10000;
const toBase64 = (str) => Buffer.from(str || '', 'utf8').toString('base64'); const toBase64 = (str) => Buffer.from(str || '', 'utf8').toString('base64');
@@ -25,47 +22,22 @@ class HttpRequestor extends BaseRequestor {
this.method = hook.method || 'POST'; this.method = hook.method || 'POST';
this.authHeader = basicAuth(hook.username, hook.password); this.authHeader = basicAuth(hook.username, hook.password);
const u = parseUrl(this.url);
const myPort = u.port ? `:${u.port}` : '';
const baseUrl = this._baseUrl = `${u.protocol}://${u.resource}${myPort}`;
this.get = bent(baseUrl, 'GET', 'buffer', 200, 201);
this.post = bent(baseUrl, 'POST', 'buffer', 200, 201);
assert(this._isAbsoluteUrl(this.url)); assert(this._isAbsoluteUrl(this.url));
assert(['GET', 'POST'].includes(this.method)); assert(['GET', 'POST'].includes(this.method));
const u = this._parsedUrl = parseUrl(this.url);
if (u.port) this._baseUrl = `${u.protocol}://${u.resource}:${u.port}`;
else this._baseUrl = `${u.protocol}://${u.resource}`;
this._protocol = u.protocol;
this._resource = u.resource;
this._port = u.port;
this._search = u.search;
this._usePools = process.env.HTTP_POOL && parseInt(process.env.HTTP_POOL);
if (this._usePools) {
if (pools.has(this._baseUrl)) {
this.client = pools.get(this._baseUrl);
}
else {
const connections = process.env.HTTP_POOLSIZE ? parseInt(process.env.HTTP_POOLSIZE) : 10;
const pipelining = process.env.HTTP_PIPELINING ? parseInt(process.env.HTTP_PIPELINING) : 1;
const pool = this.client = new Pool(this._baseUrl, {
connections,
pipelining
});
pools.set(this._baseUrl, pool);
this.logger.debug(`HttpRequestor:created pool for ${this._baseUrl}`);
}
}
else {
if (u.port) this.client = new Client(`${u.protocol}://${u.resource}:${u.port}`);
else this.client = new Client(`${u.protocol}://${u.resource}`);
}
} }
get baseUrl() { get baseUrl() {
return this._baseUrl; return this._baseUrl;
} }
close() {
if (!this._usePools && !this.client?.closed) this.client.close();
}
/** /**
* Make an HTTP request. * Make an HTTP request.
* All requests use json bodies. * All requests use json bodies.
@@ -77,90 +49,29 @@ class HttpRequestor extends BaseRequestor {
* @param {string} [hook.password] - if basic auth is protecting the endpoint * @param {string} [hook.password] - if basic auth is protecting the endpoint
* @param {object} [params] - request parameters * @param {object} [params] - request parameters
*/ */
async request(type, hook, params, httpHeaders = {}) { async request(type, hook, params) {
/* jambonz:error only sent over ws */
if (type === 'jambonz:error') return;
assert(HookMsgTypes.includes(type)); assert(HookMsgTypes.includes(type));
const payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null; const payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null;
const url = hook.url || hook; const url = hook.url || hook;
const method = hook.method || 'POST'; const method = hook.method || 'POST';
let buf = '';
assert.ok(url, 'HttpRequestor:request url was not provided'); assert.ok(url, 'HttpRequestor:request url was not provided');
assert.ok, (['GET', 'POST'].includes(method), `HttpRequestor:request method must be 'GET' or 'POST' not ${method}`); assert.ok, (['GET', 'POST'].includes(method), `HttpRequestor:request method must be 'GET' or 'POST' not ${method}`);
const {url: urlInfo = hook, method: methodInfo = 'POST'} = hook; // mask user/pass
this.logger.debug({url: urlInfo, method: methodInfo, payload}, `HttpRequestor:request ${method} ${url}`);
const startAt = process.hrtime(); const startAt = process.hrtime();
/* if we have an absolute url, and it is ws then do a websocket connection */ let buf;
if (this._isAbsoluteUrl(url) && url.startsWith('ws')) {
this.logger.debug({hook}, 'HttpRequestor: switching to websocket connection');
const h = typeof hook === 'object' ? hook : {url: hook};
const requestor = new WsRequestor(this.logger, this.account_sid, h, this.secret);
if (type === 'session:redirect') {
this.close();
this.emit('handover', requestor);
}
return requestor.request('session:new', hook, params, httpHeaders);
}
let newClient;
try { try {
let client, path, query;
if (this._isRelativeUrl(url)) {
client = this.client;
path = url;
}
else {
const u = parseUrl(url);
if (u.resource === this._resource && u.port === this._port && u.protocol === this._protocol) {
client = this.client;
path = u.pathname;
query = u.query;
}
else {
if (u.port) client = newClient = new Client(`${u.protocol}://${u.resource}:${u.port}`);
else client = newClient = new Client(`${u.protocol}://${u.resource}`);
path = u.pathname;
query = u.query;
}
}
const sigHeader = this._generateSigHeader(payload, this.secret); const sigHeader = this._generateSigHeader(payload, this.secret);
const hdrs = { const headers = {...sigHeader, ...this.authHeader};
...sigHeader, //this.logger.info({url, headers}, 'send webhook');
...this.authHeader, buf = this._isRelativeUrl(url) ?
...httpHeaders, await this.post(url, payload, headers) :
...('POST' === method && {'Content-Type': 'application/json'}) await bent(method, 'buffer', 200, 201, 202)(url, payload, headers);
};
const absUrl = this._isRelativeUrl(url) ? `${this.baseUrl}${url}` : url;
this.logger.debug({url, absUrl, hdrs}, 'send webhook');
const {statusCode, headers, body} = await client.request({
path,
query,
method,
headers: hdrs,
...('POST' === method && {body: JSON.stringify(payload)}),
timeout: HTTP_TIMEOUT,
followRedirects: false
});
if (![200, 202, 204].includes(statusCode)) {
const err = new Error();
err.statusCode = statusCode;
throw err;
}
if (headers['content-type']?.includes('application/json')) {
buf = await body.json();
}
if (newClient) newClient.close();
} catch (err) { } catch (err) {
if (err.statusCode) { this.logger.error({err, secret: this.secret, baseUrl: this.baseUrl, url, statusCode: err.statusCode},
this.logger.info({baseUrl: this.baseUrl, url}, `web callback returned unexpected error code ${err.statusCode}`);
`web callback returned unexpected status code ${err.statusCode}`);
}
else {
this.logger.error({err, baseUrl: this.baseUrl, url},
'web callback returned unexpected error');
}
let opts = {account_sid: this.account_sid}; let opts = {account_sid: this.account_sid};
if (err.code === 'ECONNREFUSED') { if (err.code === 'ECONNREFUSED') {
opts = {...opts, alert_type: this.Alerter.AlertType.WEBHOOK_CONNECTION_FAILURE, url}; opts = {...opts, alert_type: this.Alerter.AlertType.WEBHOOK_CONNECTION_FAILURE, url};
@@ -173,15 +84,20 @@ class HttpRequestor extends BaseRequestor {
} }
this.Alerter.writeAlerts(opts).catch((err) => this.logger.info({err, opts}, 'Error writing alert')); this.Alerter.writeAlerts(opts).catch((err) => this.logger.info({err, opts}, 'Error writing alert'));
if (newClient) newClient.close();
throw err; throw err;
} }
const rtt = this._roundTrip(startAt); const rtt = this._roundTrip(startAt);
if (buf) this.stats.histogram('app.hook.response_time', rtt, ['hook_type:app']); if (buf) this.stats.histogram('app.hook.response_time', rtt, ['hook_type:app']);
if (buf && Array.isArray(buf)) { if (buf && buf.toString().length > 0) {
this.logger.info({response: buf}, `HttpRequestor:request ${method} ${url} succeeded in ${rtt}ms`); try {
return buf; const json = JSON.parse(buf.toString());
this.logger.info({response: json}, `HttpRequestor:request ${method} ${url} succeeded in ${rtt}ms`);
return json;
}
catch (err) {
//this.logger.debug({err, url, method}, `HttpRequestor:request returned non-JSON content: '${buf.toString()}'`);
}
} }
} }
} }

View File

@@ -1,5 +1,6 @@
const Mrf = require('drachtio-fsmrf'); const Mrf = require('drachtio-fsmrf');
const ip = require('ip'); const ip = require('ip');
const localIp = ip.address();
const PORT = process.env.HTTP_PORT || 3000; const PORT = process.env.HTTP_PORT || 3000;
const assert = require('assert'); const assert = require('assert');
@@ -31,7 +32,6 @@ function initMS(logger, wrapper, ms) {
function installSrfLocals(srf, logger) { function installSrfLocals(srf, logger) {
logger.debug('installing srf locals'); logger.debug('installing srf locals');
assert(!srf.locals.dbHelpers); assert(!srf.locals.dbHelpers);
const {tracer} = srf.locals.otel;
const {getSBC, lifecycleEmitter} = require('./sbc-pinger')(logger); const {getSBC, lifecycleEmitter} = require('./sbc-pinger')(logger);
const StatsCollector = require('@jambonz/stats-collector'); const StatsCollector = require('@jambonz/stats-collector');
const stats = srf.locals.stats = new StatsCollector(logger); const stats = srf.locals.stats = new StatsCollector(logger);
@@ -49,11 +49,7 @@ function installSrfLocals(srf, logger) {
assert.ok(arr, `Invalid syntax JAMBONES_FREESWITCH: ${process.env.JAMBONES_FREESWITCH}`); assert.ok(arr, `Invalid syntax JAMBONES_FREESWITCH: ${process.env.JAMBONES_FREESWITCH}`);
const opts = {address: arr[1], port: arr[2], secret: arr[3]}; const opts = {address: arr[1], port: arr[2], secret: arr[3]};
if (arr.length > 4) opts.advertisedAddress = arr[4]; if (arr.length > 4) opts.advertisedAddress = arr[4];
/* NB: originally for testing only, but for now all jambonz deployments
have freeswitch installed locally alongside this app
*/
if (process.env.NODE_ENV === 'test') opts.listenAddress = '0.0.0.0'; if (process.env.NODE_ENV === 'test') opts.listenAddress = '0.0.0.0';
else if (process.env.JAMBONES_ESL_LISTEN_ADDRESS) opts.listenAddress = process.env.JAMBONES_ESL_LISTEN_ADDRESS;
return opts; return opts;
}); });
logger.info({fsInventory}, 'freeswitch inventory'); logger.info({fsInventory}, 'freeswitch inventory');
@@ -66,7 +62,7 @@ function installSrfLocals(srf, logger) {
initMS(logger, val, ms); initMS(logger, val, ms);
} }
catch (err) { catch (err) {
logger.info({err}, `failed connecting to freeswitch at ${fs.address}, will retry shortly: ${err.message}`); logger.info(`failed connecting to freeswitch at ${fs.address}, will retry shortly`);
} }
} }
// retry to connect to any that were initially offline // retry to connect to any that were initially offline
@@ -78,7 +74,7 @@ function installSrfLocals(srf, logger) {
const ms = await mrf.connect(val.opts); const ms = await mrf.connect(val.opts);
initMS(logger, val, ms); initMS(logger, val, ms);
} catch (err) { } catch (err) {
logger.info({err}, `failed connecting to freeswitch at ${val.opts.address}, will retry shortly`); logger.info(`failed connecting to freeswitch at ${val.opts.address}, will retry shortly`);
} }
} }
} }
@@ -131,7 +127,7 @@ function installSrfLocals(srf, logger) {
password: process.env.JAMBONES_MYSQL_PASSWORD, password: process.env.JAMBONES_MYSQL_PASSWORD,
database: process.env.JAMBONES_MYSQL_DATABASE, database: process.env.JAMBONES_MYSQL_DATABASE,
connectionLimit: process.env.JAMBONES_MYSQL_CONNECTION_LIMIT || 10 connectionLimit: process.env.JAMBONES_MYSQL_CONNECTION_LIMIT || 10
}, logger, tracer); }, logger);
const { const {
client, client,
updateCallStatus, updateCallStatus,
@@ -152,13 +148,11 @@ function installSrfLocals(srf, logger) {
popFront, popFront,
removeFromList, removeFromList,
lengthOfList, lengthOfList,
getListPosition, getListPosition
getNuanceAccessToken,
getIbmAccessToken,
} = require('@jambonz/realtimedb-helpers')({ } = require('@jambonz/realtimedb-helpers')({
host: process.env.JAMBONES_REDIS_HOST, host: process.env.JAMBONES_REDIS_HOST,
port: process.env.JAMBONES_REDIS_PORT || 6379 port: process.env.JAMBONES_REDIS_PORT || 6379
}, logger, tracer); }, logger);
const { const {
writeAlerts, writeAlerts,
AlertType AlertType
@@ -168,13 +162,6 @@ function installSrfLocals(srf, logger) {
commitInterval: 'test' === process.env.NODE_ENV ? 7 : 20 commitInterval: 'test' === process.env.NODE_ENV ? 7 : 20
}); });
let localIp;
try {
localIp = ip.address();
} catch (err) {
logger.error({err}, 'installSrfLocals - error detecting local ipv4 address');
}
srf.locals = {...srf.locals, srf.locals = {...srf.locals,
dbHelpers: { dbHelpers: {
client, client,
@@ -206,11 +193,11 @@ function installSrfLocals(srf, logger) {
popFront, popFront,
removeFromList, removeFromList,
lengthOfList, lengthOfList,
getListPosition, getListPosition
getNuanceAccessToken,
getIbmAccessToken
}, },
parentLogger: logger, parentLogger: logger,
ipv4: localIp,
serviceUrl: `http://${localIp}:${PORT}`,
getSBC, getSBC,
getSmpp: () => { getSmpp: () => {
return process.env.SMPP_URL; return process.env.SMPP_URL;
@@ -221,11 +208,6 @@ function installSrfLocals(srf, logger) {
writeAlerts, writeAlerts,
AlertType AlertType
}; };
if (localIp) {
srf.locals.ipv4 = localIp;
srf.locals.serviceUrl = `http://${localIp}:${PORT}`;
}
} }
module.exports = installSrfLocals; module.exports = installSrfLocals;

View File

@@ -4,30 +4,24 @@ const SipError = require('drachtio-srf').SipError;
const {TaskPreconditions, CallDirection} = require('../utils/constants'); const {TaskPreconditions, CallDirection} = require('../utils/constants');
const CallInfo = require('../session/call-info'); const CallInfo = require('../session/call-info');
const assert = require('assert'); const assert = require('assert');
const normalizeJambones = require('../utils/normalize-jambones');
const makeTask = require('../tasks/make_task');
const ConfirmCallSession = require('../session/confirm-call-session'); const ConfirmCallSession = require('../session/confirm-call-session');
const AdultingCallSession = require('../session/adulting-call-session'); const AdultingCallSession = require('../session/adulting-call-session');
const deepcopy = require('deepcopy'); const deepcopy = require('deepcopy');
const moment = require('moment'); const moment = require('moment');
const stripCodecs = require('./strip-ancillary-codecs'); const stripCodecs = require('./strip-ancillary-codecs');
const RootSpan = require('./call-tracer'); const { v4: uuidv4 } = require('uuid');
const uuidv4 = require('uuid-random');
class SingleDialer extends Emitter { class SingleDialer extends Emitter {
constructor({logger, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan}) { constructor({logger, sbcAddress, target, opts, application, callInfo, accountInfo}) {
super(); super();
assert(target.type); assert(target.type);
this.logger = logger; this.logger = logger;
this.target = target; this.target = target;
this.from = target.from || {};
this.sbcAddress = sbcAddress; this.sbcAddress = sbcAddress;
this.opts = opts; this.opts = opts;
this.application = application; this.application = application;
this.confirmHook = target.confirmHook; this.confirmHook = target.confirmHook;
this.rootSpan = rootSpan;
this.startSpan = startSpan;
this.bindings = logger.bindings(); this.bindings = logger.bindings();
@@ -67,11 +61,8 @@ class SingleDialer extends Emitter {
opts.headers = { opts.headers = {
...opts.headers, ...opts.headers,
...(this.target.headers || {}), ...(this.target.headers || {}),
...(this.from.user && {'X-Preferred-From-User': this.from.user}),
...(this.from.host && {'X-Preferred-From-Host': this.from.host}),
'X-Jambonz-Routing': this.target.type, 'X-Jambonz-Routing': this.target.type,
'X-Call-Sid': this.callSid, 'X-Call-Sid': this.callSid
...(this.applicationSid && {'X-Application-Sid': this.applicationSid})
}; };
if (srf.locals.fsUUID) { if (srf.locals.fsUUID) {
opts.headers = { opts.headers = {
@@ -80,7 +71,7 @@ class SingleDialer extends Emitter {
}; };
} }
this.ms = ms; this.ms = ms;
let uri, to, inviteSpan; let uri, to;
try { try {
switch (this.target.type) { switch (this.target.type) {
case 'phone': case 'phone':
@@ -146,24 +137,13 @@ class SingleDialer extends Emitter {
localSdp: this.ep.local.sdp localSdp: this.ep.local.sdp
}); });
if (this.target.auth) opts.auth = this.target.auth; if (this.target.auth) opts.auth = this.target.auth;
inviteSpan = this.startSpan('invite', {
'invite.uri': uri,
'invite.dest_type': this.target.type
});
this.dlg = await srf.createUAC(uri, {...opts, followRedirects: true, keepUriOnRedirect: true}, { this.dlg = await srf.createUAC(uri, {...opts, followRedirects: true, keepUriOnRedirect: true}, {
cbRequest: (err, req) => { cbRequest: (err, req) => {
if (err) { if (err) {
this.logger.error(err, 'SingleDialer:exec Error creating call'); this.logger.error(err, 'SingleDialer:exec Error creating call');
this.emit('callCreateFail', err); this.emit('callCreateFail', err);
inviteSpan.setAttributes({
'invite.status_code': 500,
'invite.err': err.message
});
inviteSpan.end();
return; return;
} }
inviteSpan.setAttributes({'invite.call_id': req.get('Call-ID')});
/** /**
* INVITE has been sent out * INVITE has been sent out
@@ -176,8 +156,7 @@ class SingleDialer extends Emitter {
parentCallInfo: this.parentCallInfo, parentCallInfo: this.parentCallInfo,
req, req,
to, to,
callSid: this.callSid, callSid: this.callSid
traceId: this.rootSpan.traceId
}); });
this.logger = srf.locals.parentLogger.child({ this.logger = srf.locals.parentLogger.child({
callSid: this.callSid, callSid: this.callSid,
@@ -185,14 +164,10 @@ class SingleDialer extends Emitter {
callId: this.callInfo.callId callId: this.callInfo.callId
}); });
this.inviteInProgress = req; this.inviteInProgress = req;
this.emit('callStatusChange', { this.emit('callStatusChange', {callStatus: CallStatus.Trying, sipStatus: 100});
callStatus: CallStatus.Trying,
sipStatus: 100,
sipReason: 'Trying'
});
}, },
cbProvisional: (prov) => { cbProvisional: (prov) => {
const status = {sipStatus: prov.status, sipReason: prov.reason}; const status = {sipStatus: prov.status};
if ([180, 183].includes(prov.status) && prov.body) { if ([180, 183].includes(prov.status) && prov.body) {
if (status.callStatus !== CallStatus.EarlyMedia) { if (status.callStatus !== CallStatus.EarlyMedia) {
status.callStatus = CallStatus.EarlyMedia; status.callStatus = CallStatus.EarlyMedia;
@@ -207,27 +182,15 @@ class SingleDialer extends Emitter {
await connectStream(this.dlg.remote.sdp); await connectStream(this.dlg.remote.sdp);
this.dlg.callSid = this.callSid; this.dlg.callSid = this.callSid;
this.inviteInProgress = null; this.inviteInProgress = null;
this.emit('callStatusChange', { this.emit('callStatusChange', {sipStatus: 200, callStatus: CallStatus.InProgress});
sipStatus: 200,
sipReason: 'OK',
callStatus: CallStatus.InProgress
});
this.logger.debug(`SingleDialer:exec call connected: ${this.callSid}`); this.logger.debug(`SingleDialer:exec call connected: ${this.callSid}`);
const connectTime = this.dlg.connectTime = moment(); const connectTime = this.dlg.connectTime = moment();
inviteSpan.setAttributes({'invite.status_code': 200});
inviteSpan.end();
/* race condition: we were killed just as call was answered */ /* race condition: we were killed just as call was answered */
if (this.killed) { if (this.killed) {
this.logger.info(`SingleDialer:exec race condition - we were killed as call connected: ${this.callSid}`); this.logger.info(`SingleDialer:exec race condition - we were killed as call connected: ${this.callSid}`);
const duration = moment().diff(connectTime, 'seconds'); const duration = moment().diff(connectTime, 'seconds');
this.emit('callStatusChange', { this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
callStatus: CallStatus.Completed,
sipStatus: 487,
sipReason: 'Request Terminated',
duration
});
if (this.ep) this.ep.destroy(); if (this.ep) this.ep.destroy();
return; return;
} }
@@ -254,9 +217,6 @@ class SingleDialer extends Emitter {
} catch (err) { } catch (err) {
this.logger.error(err, 'Error handling reinvite'); this.logger.error(err, 'Error handling reinvite');
} }
})
.on('refer', (req, res) => {
this.emit('refer', this.callInfo, req, res);
}); });
if (this.confirmHook) this._executeApp(this.confirmHook); if (this.confirmHook) this._executeApp(this.confirmHook);
@@ -266,21 +226,13 @@ class SingleDialer extends Emitter {
const status = {callStatus: CallStatus.Failed}; const status = {callStatus: CallStatus.Failed};
if (err instanceof SipError) { if (err instanceof SipError) {
status.sipStatus = err.status; status.sipStatus = err.status;
status.sipReason = err.reason;
if (err.status === 487) status.callStatus = CallStatus.NoAnswer; if (err.status === 487) status.callStatus = CallStatus.NoAnswer;
else if ([486, 600].includes(err.status)) status.callStatus = CallStatus.Busy; else if ([486, 600].includes(err.status)) status.callStatus = CallStatus.Busy;
this.logger.info(`SingleDialer:exec outdial failure ${err.status}`); this.logger.info(`SingleDialer:exec outdial failure ${err.status}`);
inviteSpan.setAttributes({'invite.status_code': err.status});
inviteSpan.end();
} }
else { else {
this.logger.error(err, 'SingleDialer:exec'); this.logger.error(err, 'SingleDialer:exec');
status.sipStatus = 500; status.sipStatus = 500;
inviteSpan.setAttributes({
'invite.status_code': 500,
'invite.err': err.message
});
inviteSpan.end();
} }
this.emit('callStatusChange', status); this.emit('callStatusChange', status);
if (this.ep) this.ep.destroy(); if (this.ep) this.ep.destroy();
@@ -315,8 +267,8 @@ class SingleDialer extends Emitter {
async _executeApp(confirmHook) { async _executeApp(confirmHook) {
try { try {
// retrieve set of tasks // retrieve set of tasks
const json = await this.requestor.request('dial:confirm', confirmHook, this.callInfo.toJSON()); const tasks = await this.requestor.request(confirmHook, this.callInfo.toJSON());
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
// verify it contains only allowed verbs // verify it contains only allowed verbs
const allowedTasks = tasks.filter((task) => { const allowedTasks = tasks.filter((task) => {
return [ return [
@@ -336,9 +288,7 @@ class SingleDialer extends Emitter {
dlg: this.dlg, dlg: this.dlg,
ep: this.ep, ep: this.ep,
callInfo: this.callInfo, callInfo: this.callInfo,
accountInfo: this.accountInfo, tasks
tasks,
rootSpan: this.rootSpan
}); });
await cs.exec(); await cs.exec();
@@ -352,6 +302,7 @@ class SingleDialer extends Emitter {
} }
async doAdulting({logger, tasks, application}) { async doAdulting({logger, tasks, application}) {
this.logger = logger;
this.adulting = true; this.adulting = true;
this.emit('adulting'); this.emit('adulting');
if (this.ep) { if (this.ep) {
@@ -362,21 +313,15 @@ class SingleDialer extends Emitter {
else { else {
await this.reAnchorMedia(); await this.reAnchorMedia();
} }
this.dlg.callSid = this.callSid;
this.dlg.linkedSpanId = this.rootSpan.traceId;
const rootSpan = new RootSpan('outbound-call', this.dlg);
const newLogger = logger.child({traceId: rootSpan.traceId});
const cs = new AdultingCallSession({ const cs = new AdultingCallSession({
logger: newLogger, logger: this.logger,
singleDialer: this, singleDialer: this,
application, application,
callInfo: this.callInfo, callInfo: this.callInfo,
accountInfo: this.accountInfo, accountInfo: this.accountInfo,
tasks, tasks
rootSpan
}); });
cs.exec().catch((err) => newLogger.error({err}, 'doAdulting: error executing session')); cs.exec();
return cs; return cs;
} }
@@ -403,16 +348,16 @@ class SingleDialer extends Emitter {
}); });
} }
_notifyCallStatusChange({callStatus, sipStatus, sipReason, duration}) { _notifyCallStatusChange({callStatus, sipStatus, duration}) {
assert((typeof duration === 'number' && callStatus === CallStatus.Completed) || assert((typeof duration === 'number' && callStatus === CallStatus.Completed) ||
(!duration && callStatus !== CallStatus.Completed), (!duration && callStatus !== CallStatus.Completed),
'duration MUST be supplied when call completed AND ONLY when call completed'); 'duration MUST be supplied when call completed AND ONLY when call completed');
if (this.callInfo) { if (this.callInfo) {
this.callInfo.updateCallStatus(callStatus, sipStatus, sipReason); this.callInfo.updateCallStatus(callStatus, sipStatus);
if (typeof duration === 'number') this.callInfo.duration = duration; if (typeof duration === 'number') this.callInfo.duration = duration;
try { try {
this.notifier.request('call:status', this.application.call_status_hook, this.callInfo.toJSON()); this.requestor.request('call:status', this.application.call_status_hook, this.callInfo.toJSON());
} catch (err) { } catch (err) {
this.logger.info(err, `SingleDialer:_notifyCallStatusChange error sending ${callStatus} ${sipStatus}`); this.logger.info(err, `SingleDialer:_notifyCallStatusChange error sending ${callStatus} ${sipStatus}`);
} }
@@ -425,13 +370,9 @@ class SingleDialer extends Emitter {
} }
} }
function placeOutdial({ function placeOutdial({logger, srf, ms, sbcAddress, target, opts, application, callInfo, accountInfo}) {
logger, srf, ms, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan
}) {
const myOpts = deepcopy(opts); const myOpts = deepcopy(opts);
const sd = new SingleDialer({ const sd = new SingleDialer({logger, sbcAddress, target, myOpts, application, callInfo, accountInfo});
logger, sbcAddress, target, myOpts, application, callInfo, accountInfo, rootSpan, startSpan
});
sd.exec(srf, ms, myOpts); sd.exec(srf, ms, myOpts);
return sd; return sd;
} }

View File

@@ -1,7 +1,42 @@
const bent = require('bent');
const parseUrl = require('parse-url');
const assert = require('assert'); const assert = require('assert');
const snakeCaseKeys = require('./snakecase-keys');
const crypto = require('crypto');
const timeSeries = require('@jambonz/time-series'); const timeSeries = require('@jambonz/time-series');
let alerter ; let alerter ;
const toBase64 = (str) => Buffer.from(str || '', 'utf8').toString('base64');
function computeSignature(payload, timestamp, secret) {
assert(secret);
const data = `${timestamp}.${JSON.stringify(payload)}`;
return crypto
.createHmac('sha256', secret)
.update(data, 'utf8')
.digest('hex');
}
function generateSigHeader(payload, secret) {
const timestamp = Math.floor(Date.now() / 1000);
const signature = computeSignature(payload, timestamp, secret);
const scheme = 'v1';
return {
'Jambonz-Signature': `t=${timestamp},${scheme}=${signature}`
};
}
function basicAuth(username, password) {
if (!username || !password) return {};
const creds = `${username}:${password || ''}`;
const header = `Basic ${toBase64(creds)}`;
return {Authorization: header};
}
function isRelativeUrl(u) {
return typeof u === 'string' && u.startsWith('/');
}
function isAbsoluteUrl(u) { function isAbsoluteUrl(u) {
return typeof u === 'string' && return typeof u === 'string' &&
u.startsWith('https://') || u.startsWith('http://'); u.startsWith('https://') || u.startsWith('http://');
@@ -14,6 +49,14 @@ class Requestor {
this.logger = logger; this.logger = logger;
this.url = hook.url; this.url = hook.url;
this.method = hook.method || 'POST'; this.method = hook.method || 'POST';
this.authHeader = basicAuth(hook.username, hook.password);
const u = parseUrl(this.url);
const myPort = u.port ? `:${u.port}` : '';
const baseUrl = this._baseUrl = `${u.protocol}://${u.resource}${myPort}`;
this.get = bent(baseUrl, 'GET', 'buffer', 200, 201);
this.post = bent(baseUrl, 'POST', 'buffer', 200, 201);
this.username = hook.username; this.username = hook.username;
this.password = hook.password; this.password = hook.password;
@@ -35,15 +78,72 @@ class Requestor {
} }
} }
get Alerter() { get baseUrl() {
if (!alerter) { return this._baseUrl;
alerter = timeSeries(this.logger, { }
host: process.env.JAMBONES_TIME_SERIES_HOST,
commitSize: 50, /**
commitInterval: 'test' === process.env.NODE_ENV ? 7 : 20 * Make an HTTP request.
}); * All requests use json bodies.
* All requests expect a 200 statusCode on success
* @param {object|string} hook - may be a absolute or relative url, or an object
* @param {string} [hook.url] - an absolute or relative url
* @param {string} [hook.method] - 'GET' or 'POST'
* @param {string} [hook.username] - if basic auth is protecting the endpoint
* @param {string} [hook.password] - if basic auth is protecting the endpoint
* @param {object} [params] - request parameters
*/
async request(hook, params) {
const payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null;
const url = hook.url || hook;
const method = hook.method || 'POST';
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}`);
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');
buf = isRelativeUrl(url) ?
await this.post(url, payload, headers) :
await bent(method, 'buffer', 200, 201, 202)(url, payload, headers);
} catch (err) {
this.logger.error({err, secret: this.secret, baseUrl: this.baseUrl, url, statusCode: err.statusCode},
`web callback returned unexpected error code ${err.statusCode}`);
let opts = {account_sid: this.account_sid};
if (err.code === 'ECONNREFUSED') {
opts = {...opts, alert_type: alerter.AlertType.WEBHOOK_CONNECTION_FAILURE, url};
}
else if (err.name === 'StatusError') {
opts = {...opts, alert_type: alerter.AlertType.WEBHOOK_STATUS_FAILURE, url, status: err.statusCode};
}
else {
opts = {...opts, alert_type: alerter.AlertType.WEBHOOK_CONNECTION_FAILURE, url, detail: err.message};
}
alerter.writeAlerts(opts).catch((err) => this.logger.info({err, opts}, 'Error writing alert'));
throw err;
}
const diff = process.hrtime(startAt);
const time = diff[0] * 1e3 + diff[1] * 1e-6;
const rtt = time.toFixed(0);
if (buf) this.stats.histogram('app.hook.response_time', rtt, ['hook_type:app']);
if (buf && buf.toString().length > 0) {
try {
const json = JSON.parse(buf.toString());
this.logger.info({response: json}, `Requestor:request ${method} ${url} succeeded in ${rtt}ms`);
return json;
}
catch (err) {
//this.logger.debug({err, url, method}, `Requestor:request returned non-JSON content: '${buf.toString()}'`);
}
} }
return alerter;
} }
} }

View File

@@ -1,5 +1,5 @@
const assert = require('assert'); const assert = require('assert');
const uuidv4 = require('uuid-random'); const { v4: uuidv4 } = require('uuid');
const {LifeCycleEvents, FS_UUID_SET_NAME} = require('./constants'); const {LifeCycleEvents, FS_UUID_SET_NAME} = require('./constants');
const Emitter = require('events'); const Emitter = require('events');
const debug = require('debug')('jambonz:feature-server'); const debug = require('debug')('jambonz:feature-server');
@@ -104,14 +104,8 @@ module.exports = (logger) => {
const {srf} = require('../..'); const {srf} = require('../..');
const {addToSet} = srf.locals.dbHelpers; const {addToSet} = srf.locals.dbHelpers;
const uuid = srf.locals.fsUUID = uuidv4(); const uuid = srf.locals.fsUUID = uuidv4();
addToSet(FS_UUID_SET_NAME, uuid)
/* in case redis is restarted, re-insert our key every so often */ .catch((err) => logger.info({err}, `Error adding ${uuid} to set ${FS_UUID_SET_NAME}`));
setInterval(() => {
// eslint-disable-next-line max-len
addToSet(FS_UUID_SET_NAME, uuid).catch((err) => logger.info({err}, `Error adding ${uuid} to set ${FS_UUID_SET_NAME}`));
}, 30000);
// eslint-disable-next-line max-len
addToSet(FS_UUID_SET_NAME, uuid).catch((err) => logger.info({err}, `Error adding ${uuid} to set ${FS_UUID_SET_NAME}`));
}); });
} }
else { else {

View File

@@ -1,248 +0,0 @@
const xmlParser = require('xml2js').parseString;
const uuidv4 = require('uuid-random');
const parseUri = require('drachtio-srf').parseUri;
const transform = require('sdp-transform');
const debug = require('debug')('jambonz:feature-server');
const parseCallData = (prefix, obj) => {
const ret = {};
const group = obj[`${prefix}group`];
if (group) {
const key = Object.keys(group[0]).find((k) => /:?callData$/.test(k));
//const o = _.find(group[0], (value, key) => /:?callData$/.test(key));
if (key) {
//const callData = o[0];
const callData = group[0][key];
for (const key of Object.keys(callData)) {
if (['fromhdr', 'tohdr', 'callid'].includes(key)) ret[key] = callData[key][0];
}
}
}
debug('parseCallData', prefix, obj, ret);
return ret;
};
/**
* parse a SIPREC multiparty body
* @param {object} opts - options
* @return {Promise}
*/
const parseSiprecPayload = (req, logger) => {
const opts = {};
return new Promise((resolve, reject) => {
let sdp, meta ;
for (let i = 0; i < req.payload.length; i++) {
switch (req.payload[i].type) {
case 'application/sdp':
sdp = req.payload[i].content ;
break ;
case 'application/rs-metadata+xml':
case 'application/rs-metadata':
meta = opts.xml = req.payload[i].content ;
break ;
default:
break ;
}
}
if (!sdp || !meta) {
logger.info({payload: req.payload}, 'invalid SIPREC payload');
return reject(new Error('expected multipart SIPREC body'));
}
xmlParser(meta, (err, result) => {
if (err) { throw err; }
opts.recordingData = result ;
opts.sessionId = uuidv4() ;
const arr = /^([^]+)(m=[^]+?)(m=[^]+?)$/.exec(sdp) ;
opts.sdp1 = `${arr[1]}${arr[2]}` ;
opts.sdp2 = `${arr[1]}${arr[3]}\r\n` ;
try {
if (typeof result === 'object' && Object.keys(result).length === 1) {
const key = Object.keys(result)[0] ;
const arr = /^(.*:)recording/.exec(key) ;
const prefix = !arr ? '' : (arr[1]) ;
const obj = opts.recordingData[`${prefix}recording`];
// 1. collect participant data
const participants = {} ;
obj[`${prefix}participant`].forEach((p) => {
const partDetails = {} ;
participants[p.$.participant_id] = partDetails;
if ((`${prefix}nameID` in p) && Array.isArray(p[`${prefix}nameID`])) {
partDetails.aor = p[`${prefix}nameID`][0].$.aor;
if ('name' in p[`${prefix}nameID`][0] && Array.isArray(p[`${prefix}nameID`][0].name)) {
const name = p[`${prefix}nameID`][0].name[0];
if (typeof name === 'string') partDetails.name = name ;
else if (typeof name === 'object') partDetails.name = name._ ;
}
}
});
// 2. find the associated streams for each participant
if (`${prefix}participantstreamassoc` in obj) {
obj[`${prefix}participantstreamassoc`].forEach((ps) => {
const part = participants[ps.$.participant_id];
if (part) {
part.send = ps[`${prefix}send`][0];
part.recv = ps[`${prefix}recv`][0];
}
});
}
// 3. Retrieve stream data
opts.caller = {} ;
opts.callee = {} ;
obj[`${prefix}stream`].forEach((s) => {
const streamId = s.$.stream_id;
let sender;
for (const [k, v] of Object.entries(participants)) {
if (v.send === streamId) {
sender = k;
break;
}
}
//const sender = _.find(participants, { 'send': streamId});
if (!sender) return;
sender.label = s[`${prefix}label`][0];
if (-1 !== ['1', 'a_leg', 'inbound'].indexOf(sender.label)) {
opts.caller.aor = sender.aor ;
if (sender.name) opts.caller.name = sender.name;
}
else {
opts.callee.aor = sender.aor ;
if (sender.name) opts.callee.name = sender.name;
}
});
// if we dont have a participantstreamassoc then assume the first participant is the caller
if (!opts.caller.aor && !opts.callee.aor) {
let i = 0;
for (const part in participants) {
const p = participants[part];
if (0 === i && p.aor) {
opts.caller.aor = p.aor;
opts.caller.name = p.name;
}
else if (1 === i && p.aor) {
opts.callee.aor = p.aor;
opts.callee.name = p.name;
}
i++;
}
}
// now for Sonus (at least) we get the original from, to and call-id headers in a <callData/> element
// if so, this should take preference
const callData = parseCallData(prefix, obj);
if (callData) {
debug(`callData: ${JSON.stringify(callData)}`);
opts.originalCallId = callData.callid;
// caller
let r1 = /^(.*)(<sip.*)$/.exec(callData.fromhdr);
if (r1) {
const arr = /<(.*)>/.exec(r1[2]);
if (arr) {
const uri = parseUri(arr[1]);
const user = uri.user || 'anonymous';
opts.caller.aor = `sip:${user}@${uri.host}`;
}
const dname = r1[1].trim();
const arr2 = /"(.*)"/.exec(dname);
if (arr2) opts.caller.name = arr2[1];
else opts.caller.name = dname;
}
// callee
r1 = /^(.*)(<sip.*)$/.exec(callData.tohdr);
if (r1) {
const arr = /<(.*)>/.exec(r1[2]);
if (arr) {
const uri = parseUri(arr[1]);
opts.callee.aor = `sip:${uri.user}@${uri.host}`;
}
const dname = r1[1].trim();
const arr2 = /"(.*)"/.exec(dname);
if (arr2) opts.callee.name = arr2[1];
else opts.callee.name = dname;
}
debug(`opts.caller from callData: ${JSON.stringify(opts.caller)}`);
debug(`opts.callee from callData: ${JSON.stringify(opts.callee)}`);
}
if (opts.caller.aor && 0 !== opts.caller.aor.indexOf('sip:')) {
opts.caller.aor = 'sip:' + opts.caller.aor;
}
if (opts.callee.aor && 0 !== opts.callee.aor.indexOf('sip:')) {
opts.callee.aor = 'sip:' + opts.callee.aor;
}
if (opts.caller.aor) {
const uri = parseUri(opts.caller.aor);
opts.caller.number = uri.user;
}
if (opts.callee.aor) {
const uri = parseUri(opts.callee.aor);
opts.callee.number = uri.user;
}
opts.recordingSessionId = opts.recordingData[`${prefix}recording`][`${prefix}session`][0].$.session_id;
}
}
catch (err) {
reject(err);
}
debug(opts, 'payload parser results');
resolve(opts) ;
}) ;
}) ;
};
const createSipRecPayload = (sdp1, sdp2, logger) => {
const sdpObj = [];
sdpObj.push(transform.parse(sdp1));
sdpObj.push(transform.parse(sdp2));
//const arr1 = /^([^]+)(c=[^]+)t=[^]+(m=[^]+?)(a=[^]+)$/.exec(sdp1) ;
//const arr2 = /^([^]+)(c=[^]+)t=[^]+(m=[^]+?)(a=[^]+)$/.exec(sdp2) ;
debug(`sdp1: ${sdp1}`);
debug(`objSdp[0]: ${JSON.stringify(sdpObj[0])}`);
debug(`sdp2: ${sdp2}`);
debug(`objSdp[1]: ${JSON.stringify(sdpObj[1])}`);
if (!sdpObj[0] || !sdpObj[0].media.length) {
throw new Error(`Error parsing sdp1 into component parts: ${sdp1}`);
}
else if (!sdpObj[1] || !sdpObj[1].media.length) {
throw new Error(`Error parsing sdp2 into component parts: ${sdp2}`);
}
if (!sdpObj[0].media[0].label) sdpObj[0].media[0].label = 1;
if (!sdpObj[1].media[0].label) sdpObj[1].media[0].label = 2;
//const aLabel = sdp1.includes('a=label:') ? '' : 'a=label:1\r\n';
//const bLabel = sdp2.includes('a=label:') ? '' : 'a=label:2\r\n';
sdpObj[0].media = sdpObj[0].media.concat(sdpObj[1].media);
const combinedSdp = transform.write(sdpObj[0])
.replace(/a=sendonly\r\n/g, '')
.replace(/a=direction:both\r\n/g, '');
debug(`combined ${combinedSdp}`);
/*
const combinedSdp = `${arr1[1]}t=0 0\r\n${arr1[2]}${arr1[3]}${arr1[4]}${aLabel}${arr2[3]}${arr2[4]}${bLabel}`
.replace(/a=sendonly\r\n/g, '')
.replace(/a=direction:both\r\n/g, '');
*/
return combinedSdp;
};
module.exports = { parseSiprecPayload, createSipRecPayload } ;

View File

@@ -1,3 +1,3 @@
module.exports = function(tasks) { module.exports = function(tasks) {
return `[${tasks.map((t) => t.summary).join(',')}]`; return `[${tasks.map((t) => t.name).join(',')}]`;
}; };

View File

@@ -1,419 +0,0 @@
const {
TaskName,
AzureTranscriptionEvents,
GoogleTranscriptionEvents,
AwsTranscriptionEvents,
NuanceTranscriptionEvents,
DeepgramTranscriptionEvents,
} = require('./constants');
const normalizeDeepgram = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
const alternatives = (evt.channel?.alternatives || [])
.map((alt) => ({
confidence: alt.confidence,
transcript: alt.transcript,
}));
return {
language_code: language,
channel_tag: channel,
is_final: evt.is_final,
alternatives,
vendor: {
name: 'deepgram',
evt: copy
}
};
};
const normalizeIbm = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
//const idx = evt.result_index;
const result = evt.results[0];
return {
language_code: language,
channel_tag: channel,
is_final: result.final,
alternatives: result.alternatives,
vendor: {
name: 'ibm',
evt: copy
}
};
};
const normalizeGoogle = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
return {
language_code: language,
channel_tag: channel,
is_final: evt.is_final,
alternatives: evt.alternatives,
vendor: {
name: 'google',
evt: copy
}
};
};
const normalizeNuance = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
return {
language_code: language,
channel_tag: channel,
is_final: evt.is_final,
alternatives: evt.alternatives,
vendor: {
name: 'nuance',
evt: copy
}
};
};
const normalizeMicrosoft = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
const nbest = evt.NBest;
const language_code = evt.PrimaryLanguage?.Language || language;
const alternatives = nbest ? nbest.map((n) => {
return {
confidence: n.Confidence,
transcript: n.Display
};
}) :
[
{
transcript: evt.DisplayText || evt.Text
}
];
return {
language_code,
channel_tag: channel,
is_final: evt.RecognitionStatus === 'Success',
alternatives,
vendor: {
name: 'microsoft',
evt: copy
}
};
};
const normalizeAws = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
return {
language_code: language,
channel_tag: channel,
is_final: evt[0].is_final,
alternatives: evt[0].alternatives,
vendor: {
name: 'aws',
evt: copy
}
};
};
module.exports = (logger) => {
const normalizeTranscription = (evt, vendor, channel, language) => {
logger.debug({ evt, vendor, channel, language }, 'normalizeTranscription');
switch (vendor) {
case 'deepgram':
return normalizeDeepgram(evt, channel, language);
case 'microsoft':
return normalizeMicrosoft(evt, channel, language);
case 'google':
return normalizeGoogle(evt, channel, language);
case 'aws':
return normalizeAws(evt, channel, language);
case 'nuance':
return normalizeNuance(evt, channel, language);
case 'ibm':
return normalizeIbm(evt, channel, language);
default:
logger.error(`Unknown vendor ${vendor}`);
return evt;
}
};
const setChannelVarsForStt = (task, sttCredentials, rOpts = {}) => {
let opts = {};
const {enable, voiceMs = 0, mode = -1} = rOpts.vad || {};
const vad = {enable, voiceMs, mode};
/* voice activity detection works across vendors */
opts = {
...opts,
...(vad.enable && {START_RECOGNIZING_ON_VAD: 1}),
...(vad.enable && vad.voiceMs && {RECOGNIZER_VAD_VOICE_MS: vad.voiceMs}),
...(vad.enable && typeof vad.mode === 'number' && {RECOGNIZER_VAD_MODE: vad.mode}),
};
if ('google' === rOpts.vendor) {
opts = {
...opts,
...(sttCredentials &&
{GOOGLE_APPLICATION_CREDENTIALS: JSON.stringify(sttCredentials.credentials)}),
...(rOpts.enhancedModel &&
{GOOGLE_SPEECH_USE_ENHANCED: 1}),
...(rOpts.separateRecognitionPerChannel &&
{GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL: 1}),
...(rOpts.profanityFilter &&
{GOOGLE_SPEECH_PROFANITY_FILTER: 1}),
...(rOpts.punctuation &&
{GOOGLE_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION: 1}),
...(rOpts.words &&
{GOOGLE_SPEECH_ENABLE_WORD_TIME_OFFSETS: 1}),
...((rOpts.singleUtterance || task.name === TaskName.Gather) &&
{GOOGLE_SPEECH_SINGLE_UTTERANCE: 1}),
...(rOpts.diarization &&
{GOOGLE_SPEECH_SPEAKER_DIARIZATION: 1}),
...(rOpts.diarization && rOpts.diarizationMinSpeakers > 0 &&
{GOOGLE_SPEECH_SPEAKER_DIARIZATION_MIN_SPEAKER_COUNT: rOpts.diarizationMinSpeakers}),
...(rOpts.diarization && rOpts.diarizationMaxSpeakers > 0 &&
{GOOGLE_SPEECH_SPEAKER_DIARIZATION_MAX_SPEAKER_COUNT: rOpts.diarizationMaxSpeakers}),
...(rOpts.enhancedModel === false &&
{GOOGLE_SPEECH_USE_ENHANCED: 0}),
...(rOpts.separateRecognitionPerChannel === false &&
{GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL: 0}),
...(rOpts.profanityFilter === false &&
{GOOGLE_SPEECH_PROFANITY_FILTER: 0}),
...(rOpts.punctuation === false &&
{GOOGLE_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION: 0}),
...(rOpts.words == false &&
{GOOGLE_SPEECH_ENABLE_WORD_TIME_OFFSETS: 0}),
...((rOpts.singleUtterance === false || task.name === TaskName.Transcribe) &&
{GOOGLE_SPEECH_SINGLE_UTTERANCE: 0}),
...(rOpts.diarization === false &&
{GOOGLE_SPEECH_SPEAKER_DIARIZATION: 0}),
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'string' &&
{GOOGLE_SPEECH_HINTS: rOpts.hints.join(',')}),
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'object' &&
{GOOGLE_SPEECH_HINTS: JSON.stringify(rOpts.hints)}),
...(typeof rOpts.hintsBoost === 'number' &&
{GOOGLE_SPEECH_HINTS_BOOST: rOpts.hintsBoost}),
...(rOpts.altLanguages.length > 0 &&
{GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES: rOpts.altLanguages.join(',')}),
...(rOpts.interactionType &&
{GOOGLE_SPEECH_METADATA_INTERACTION_TYPE: rOpts.interactionType}),
...{GOOGLE_SPEECH_MODEL: rOpts.model || (task.name === TaskName.Gather ? 'command_and_search' : 'phone_call')},
...(rOpts.naicsCode > 0 &&
{GOOGLE_SPEECH_METADATA_INDUSTRY_NAICS_CODE: rOpts.naicsCode}),
};
}
else if (['aws', 'polly'].includes(rOpts.vendor)) {
opts = {
...opts,
...(rOpts.vocabularyName && {AWS_VOCABULARY_NAME: rOpts.vocabularyName}),
...(rOpts.vocabularyFilterName && {AWS_VOCABULARY_FILTER_NAME: rOpts.vocabularyFilterName}),
...(rOpts.filterMethod && {AWS_VOCABULARY_FILTER_METHOD: rOpts.filterMethod}),
...(sttCredentials && {
AWS_ACCESS_KEY_ID: sttCredentials.accessKeyId,
AWS_SECRET_ACCESS_KEY: sttCredentials.secretAccessKey,
AWS_REGION: sttCredentials.region
}),
};
}
else if ('microsoft' === rOpts.vendor) {
opts = {
...opts,
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'string' &&
{AZURE_SPEECH_HINTS: rOpts.hints.map((h) => h.trim()).join(',')}),
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'object' &&
{AZURE_SPEECH_HINTS: rOpts.hints.map((h) => h.phrase).join(',')}),
...(rOpts.altLanguages && rOpts.altLanguages.length > 0 &&
{AZURE_SERVICE_ENDPOINT_ID: rOpts.sttCredentials}),
...(rOpts.requestSnr && {AZURE_REQUEST_SNR: 1}),
...(rOpts.profanityOption && {AZURE_PROFANITY_OPTION: rOpts.profanityOption}),
...(rOpts.azureServiceEndpoint && {AZURE_SERVICE_ENDPOINT: rOpts.azureServiceEndpoint}),
...(rOpts.initialSpeechTimeoutMs > 0 &&
{AZURE_INITIAL_SPEECH_TIMEOUT_MS: rOpts.initialSpeechTimeoutMs}),
...(rOpts.requestSnr && {AZURE_REQUEST_SNR: 1}),
...(rOpts.audioLogging && {AZURE_AUDIO_LOGGING: 1}),
...{AZURE_USE_OUTPUT_FORMAT_DETAILED: 1},
...(sttCredentials && {
AZURE_SUBSCRIPTION_KEY: sttCredentials.api_key,
AZURE_REGION: sttCredentials.region,
}),
...(sttCredentials.use_custom_stt && sttCredentials.custom_stt_endpoint &&
{AZURE_SERVICE_ENDPOINT_ID: sttCredentials.custom_stt_endpoint})
};
}
else if ('nuance' === rOpts.vendor) {
/**
* Note: all nuance options are in recognizer.nuanceOptions, should migrate
* other vendor settings to similar nested structure
*/
const {nuanceOptions = {}} = rOpts;
opts = {
...opts,
...(sttCredentials.access_token) &&
{NUANCE_ACCESS_TOKEN: sttCredentials.access_token},
...(sttCredentials.krypton_endpoint) &&
{NUANCE_KRYPTON_ENDPOINT: sttCredentials.krypton_endpoint},
...(nuanceOptions.topic) &&
{NUANCE_TOPIC: nuanceOptions.topic},
...(nuanceOptions.utteranceDetectionMode) &&
{NUANCE_UTTERANCE_DETECTION_MODE: nuanceOptions.utteranceDetectionMode},
...(nuanceOptions.punctuation) && {NUANCE_PUNCTUATION: nuanceOptions.punctuation},
...(nuanceOptions.profanityFilter) &&
{NUANCE_FILTER_PROFANITY: nuanceOptions.profanityFilter},
...(nuanceOptions.includeTokenization) &&
{NUANCE_INCLUDE_TOKENIZATION: nuanceOptions.includeTokenization},
...(nuanceOptions.discardSpeakerAdaptation) &&
{NUANCE_DISCARD_SPEAKER_ADAPTATION: nuanceOptions.discardSpeakerAdaptation},
...(nuanceOptions.suppressCallRecording) &&
{NUANCE_SUPPRESS_CALL_RECORDING: nuanceOptions.suppressCallRecording},
...(nuanceOptions.maskLoadFailures) &&
{NUANCE_MASK_LOAD_FAILURES: nuanceOptions.maskLoadFailures},
...(nuanceOptions.suppressInitialCapitalization) &&
{NUANCE_SUPPRESS_INITIAL_CAPITALIZATION: nuanceOptions.suppressInitialCapitalization},
...(nuanceOptions.allowZeroBaseLmWeight)
&& {NUANCE_ALLOW_ZERO_BASE_LM_WEIGHT: nuanceOptions.allowZeroBaseLmWeight},
...(nuanceOptions.filterWakeupWord) &&
{NUANCE_FILTER_WAKEUP_WORD: nuanceOptions.filterWakeupWord},
...(nuanceOptions.resultType) &&
{NUANCE_RESULT_TYPE: nuanceOptions.resultType || rOpts.interim ? 'partial' : 'final'},
...(nuanceOptions.noInputTimeoutMs) &&
{NUANCE_NO_INPUT_TIMEOUT_MS: nuanceOptions.noInputTimeoutMs},
...(nuanceOptions.recognitionTimeoutMs) &&
{NUANCE_RECOGNITION_TIMEOUT_MS: nuanceOptions.recognitionTimeoutMs},
...(nuanceOptions.utteranceEndSilenceMs) &&
{NUANCE_UTTERANCE_END_SILENCE_MS: nuanceOptions.utteranceEndSilenceMs},
...(nuanceOptions.maxHypotheses) &&
{NUANCE_MAX_HYPOTHESES: nuanceOptions.maxHypotheses},
...(nuanceOptions.speechDomain) &&
{NUANCE_SPEECH_DOMAIN: nuanceOptions.speechDomain},
...(nuanceOptions.formatting) &&
{NUANCE_FORMATTING: nuanceOptions.formatting},
...(nuanceOptions.resources) &&
{NUANCE_RESOURCES: JSON.stringify(nuanceOptions.resources)},
};
}
else if ('deepgram' === rOpts.vendor) {
const {deepgramOptions = {}} = rOpts;
opts = {
...opts,
...(sttCredentials.api_key) &&
{DEEPGRAM_API_KEY: sttCredentials.api_key},
...(deepgramOptions.tier) &&
{DEEPGRAM_SPEECH_TIER: deepgramOptions.tier},
...(deepgramOptions.model) &&
{DEEPGRAM_SPEECH_MODEL: deepgramOptions.model},
...(deepgramOptions.punctuate) &&
{DEEPGRAM_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION: 1},
...(deepgramOptions.profanityFilter) &&
{DEEPGRAM_SPEECH_PROFANITY_FILTER: 1},
...(deepgramOptions.redact) &&
{DEEPGRAM_SPEECH_REDACT: 1},
...(deepgramOptions.diarize) &&
{DEEPGRAM_SPEECH_DIARIZE: 1},
...(deepgramOptions.diarizeVersion) &&
{DEEPGRAM_SPEECH_DIARIZE_VERSION: deepgramOptions.diarizeVersion},
...(deepgramOptions.ner) &&
{DEEPGRAM_SPEECH_NER: 1},
...(deepgramOptions.alternatives) &&
{DEEPGRAM_SPEECH_ALTERNATIVES: deepgramOptions.alternatives},
...(deepgramOptions.numerals) &&
{DEEPGRAM_SPEECH_NUMERALS: deepgramOptions.numerals},
...(deepgramOptions.search) &&
{DEEPGRAM_SPEECH_SEARCH: deepgramOptions.search.join(',')},
...(deepgramOptions.replace) &&
{DEEPGRAM_SPEECH_REPLACE: deepgramOptions.replace.join(',')},
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'string' &&
{DEEPGRAM_SPEECH_KEYWORDS: rOpts.hints.map((h) => h.trim()).join(',')}),
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'object' &&
{DEEPGRAM_SPEECH_KEYWORDS: rOpts.hints.map((h) => h.phrase).join(',')}),
...(deepgramOptions.keywords) &&
{DEEPGRAM_SPEECH_KEYWORDS: deepgramOptions.keywords.join(',')},
...('endpointing' in deepgramOptions) &&
{DEEPGRAM_SPEECH_ENDPOINTING: deepgramOptions.endpointing},
...(deepgramOptions.vadTurnoff) &&
{DEEPGRAM_SPEECH_VAD_TURNOFF: deepgramOptions.vadTurnoff},
...(deepgramOptions.tag) &&
{DEEPGRAM_SPEECH_VAD_TURNOFF: deepgramOptions.tag}
};
}
else if ('ibm' === rOpts.vendor) {
const {ibmOptions = {}} = rOpts;
opts = {
...opts,
...(sttCredentials.access_token) &&
{IBM_ACCESS_TOKEN: sttCredentials.access_token},
...(sttCredentials.stt_region) &&
{IBM_SPEECH_REGION: sttCredentials.stt_region},
...(sttCredentials.instance_id) &&
{IBM_SPEECH_INSTANCE_ID: sttCredentials.instance_id},
...(ibmOptions.model) &&
{IBM_SPEECH_MODEL: ibmOptions.model},
...(ibmOptions.language_customization_id) &&
{IBM_SPEECH_LANGUAGE_CUSTOMIZATION_ID: ibmOptions.language_customization_id},
...(ibmOptions.acoustic_customization_id) &&
{IBM_SPEECH_ACOUSTIC_CUSTOMIZATION_ID: ibmOptions.acoustic_customization_id},
...(ibmOptions.baseModelVersion) &&
{IBM_SPEECH_BASE_MODEL_VERSION: ibmOptions.baseModelVersion},
...(ibmOptions.watsonMetadata) &&
{IBM_SPEECH_WATSON_METADATA: ibmOptions.watsonMetadata},
...(ibmOptions.watsonLearningOptOut) &&
{IBM_SPEECH_WATSON_LEARNING_OPT_OUT: ibmOptions.watsonLearningOptOut}
};
}
logger.debug({opts}, 'recognizer channel vars');
return opts;
};
const removeSpeechListeners = (ep) => {
ep.removeCustomEventListener(GoogleTranscriptionEvents.Transcription);
ep.removeCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance);
ep.removeCustomEventListener(GoogleTranscriptionEvents.VadDetected);
ep.removeCustomEventListener(AwsTranscriptionEvents.Transcription);
ep.removeCustomEventListener(AwsTranscriptionEvents.VadDetected);
ep.removeCustomEventListener(AzureTranscriptionEvents.Transcription);
ep.removeCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected);
ep.removeCustomEventListener(AzureTranscriptionEvents.VadDetected);
ep.removeCustomEventListener(NuanceTranscriptionEvents.Transcription);
ep.removeCustomEventListener(NuanceTranscriptionEvents.TranscriptionComplete);
ep.removeCustomEventListener(NuanceTranscriptionEvents.StartOfSpeech);
ep.removeCustomEventListener(NuanceTranscriptionEvents.Error);
ep.removeCustomEventListener(NuanceTranscriptionEvents.VadDetected);
ep.removeCustomEventListener(DeepgramTranscriptionEvents.Transcription);
ep.removeCustomEventListener(DeepgramTranscriptionEvents.Connect);
ep.removeCustomEventListener(DeepgramTranscriptionEvents.ConnectFailure);
};
const setSpeechCredentialsAtRuntime = (recognizer) => {
if (!recognizer) return;
if (recognizer.vendor === 'nuance') {
const {clientId, secret} = recognizer.nuanceOptions || {};
if (clientId && secret) return {client_id: clientId, secret};
}
else if (recognizer.vendor === 'deepgram') {
const {apiKey} = recognizer.deepgramOptions || {};
if (apiKey) return {api_key: apiKey};
}
else if (recognizer.vendor === 'ibm') {
const {ttsApiKey, ttsRegion, sttApiKey, sttRegion, instanceId} = recognizer.ibmOptions || {};
if (ttsApiKey || sttApiKey) return {
tts_api_key: ttsApiKey,
tts_region: ttsRegion,
stt_api_key: sttApiKey,
stt_region: sttRegion,
instance_id: instanceId
};
}
};
return {
normalizeTranscription,
setChannelVarsForStt,
removeSpeechListeners,
setSpeechCredentialsAtRuntime
};
};

View File

@@ -14,11 +14,6 @@ class WsRequestor extends BaseRequestor {
this.connections = 0; this.connections = 0;
this.messagesInFlight = new Map(); this.messagesInFlight = new Map();
this.maliciousClient = false; this.maliciousClient = false;
this.closedGracefully = false;
this.backoffMs = 500;
this.connectInProgress = false;
this.queuedMsg = [];
this.id = short.generate();
assert(this._isAbsoluteUrl(this.url)); assert(this._isAbsoluteUrl(this.url));
@@ -36,7 +31,7 @@ class WsRequestor extends BaseRequestor {
* @param {string} [hook.password] - if basic auth is protecting the endpoint * @param {string} [hook.password] - if basic auth is protecting the endpoint
* @param {object} [params] - request parameters * @param {object} [params] - request parameters
*/ */
async request(type, hook, params, httpHeaders = {}) { async request(type, hook, params) {
assert(HookMsgTypes.includes(type)); assert(HookMsgTypes.includes(type));
const url = hook.url || hook; const url = hook.url || hook;
@@ -44,37 +39,18 @@ class WsRequestor extends BaseRequestor {
this.logger.info({url: this.url}, 'WsRequestor:request - discarding msg to malicious client'); this.logger.info({url: this.url}, 'WsRequestor:request - discarding msg to malicious client');
return; return;
} }
if (this.closedGracefully) {
this.logger.debug(`WsRequestor:request - discarding ${type} because we closed the socket`);
return;
}
if (type === 'session:new') this.call_sid = params.callSid;
/* if we have an absolute url, and it is http then do a standard webhook */ /* if we have an absolute url, and it is http then do a standard webhook */
if (this._isAbsoluteUrl(url) && url.startsWith('http')) { if (this._isAbsoluteUrl(url) && url.startsWith('http')) {
this.logger.debug({hook}, 'WsRequestor: sending a webhook (HTTP)'); this.logger.debug({hook}, 'WsRequestor: sending a webhook');
const h = typeof hook === 'object' ? hook : {url: hook}; const requestor = new HttpRequestor(this.logger, this.account_sid, hook, this.secret);
const requestor = new HttpRequestor(this.logger, this.account_sid, h, this.secret); return requestor.request(type, hook, params);
if (type === 'session:redirect') {
this.close();
this.emit('handover', requestor);
}
return requestor.request(type, hook, params, httpHeaders);
} }
/* connect if necessary */ /* connect if necessary */
if (!this.ws) { if (!this.ws) {
if (this.connectInProgress) {
this.logger.debug(
`WsRequestor:request(${this.id}) - queueing ${type} message since we are connecting`);
this.queuedMsg.push({type, hook, params, httpHeaders});
return;
}
this.connectInProgress = true;
this.logger.debug(`WsRequestor:request(${this.id}) - connecting since we do not have a connection`);
if (this.connections >= MAX_RECONNECTS) { if (this.connections >= MAX_RECONNECTS) {
return Promise.reject(`max attempts connecting to ${this.url}`); throw new Error(`max attempts connecting to ${this.url}`);
} }
try { try {
const startAt = process.hrtime(); const startAt = process.hrtime();
@@ -82,48 +58,29 @@ class WsRequestor extends BaseRequestor {
const rtt = this._roundTrip(startAt); const rtt = this._roundTrip(startAt);
this.stats.histogram('app.hook.connect_time', rtt, ['hook_type:app']); this.stats.histogram('app.hook.connect_time', rtt, ['hook_type:app']);
} catch (err) { } catch (err) {
this.logger.info({url, err}, 'WsRequestor:request - failed connecting'); this.logger.error({err}, 'WsRequestor:request - failed connecting');
this.connectInProgress = false; throw err;
return Promise.reject(err);
} }
} }
assert(this.ws); assert(this.ws);
/* prepare and send message */ /* prepare and send message */
let payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null; const payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null;
if (type === 'session:new') this._sessionData = payload;
if (type === 'session:reconnect') payload = this._sessionData;
assert.ok(url, 'WsRequestor:request url was not provided'); assert.ok(url, 'WsRequestor:request url was not provided');
const msgid = short.generate(); const msgid = short.generate();
const b3 = httpHeaders?.b3 ? {b3: httpHeaders.b3} : {};
const obj = { const obj = {
type, type,
msgid, msgid,
call_sid: this.call_sid,
hook: type === 'verb:hook' ? url : undefined, hook: type === 'verb:hook' ? url : undefined,
data: {...payload}, data: {...payload}
...b3
}; };
const sendQueuedMsgs = () => { this.logger.debug({obj}, `WsRequestor:request ${url}`);
if (this.queuedMsg.length > 0) {
for (const {type, hook, params, httpHeaders} of this.queuedMsg) {
this.logger.debug(`WsRequestor:request - preparing queued ${type} for sending`);
setImmediate(this.request.bind(this, type, hook, params, httpHeaders));
}
this.queuedMsg.length = 0;
}
};
//this.logger.debug({obj}, `websocket: sending (${url})`);
/* simple notifications */ /* simple notifications */
if (['call:status', 'jambonz:error', 'session:reconnect'].includes(type)) { if (['call:status', 'jambonz:error'].includes(type)) {
this.ws.send(JSON.stringify(obj), () => { this.ws.send(JSON.stringify(obj));
this.logger.debug({obj}, `WsRequestor:request websocket: sent (${url})`);
sendQueuedMsgs();
});
return; return;
} }
@@ -139,11 +96,10 @@ class WsRequestor extends BaseRequestor {
/* save the message info for reply */ /* save the message info for reply */
const startAt = process.hrtime(); const startAt = process.hrtime();
this.messagesInFlight.set(msgid, { this.messagesInFlight.set(msgid, {
timer,
success: (response) => { success: (response) => {
clearTimeout(timer); clearTimeout(timer);
const rtt = this._roundTrip(startAt); const rtt = this._roundTrip(startAt);
this.logger.debug({response}, `WsRequestor:request ${url} succeeded in ${rtt}ms`); this.logger.info({response}, `WsRequestor:request ${url} succeeded in ${rtt}ms`);
this.stats.histogram('app.hook.ws_response_time', rtt, ['hook_type:app']); this.stats.histogram('app.hook.ws_response_time', rtt, ['hook_type:app']);
resolve(response); resolve(response);
}, },
@@ -154,57 +110,38 @@ class WsRequestor extends BaseRequestor {
}); });
/* send the message */ /* send the message */
this.ws.send(JSON.stringify(obj), () => { this.ws.send(JSON.stringify(obj));
this.logger.debug({obj}, `WsRequestor:request websocket: sent (${url})`);
sendQueuedMsgs();
});
}); });
} }
close() { close() {
this.closedGracefully = true; this.logger.info('WsRequestor: closing socket');
this.logger.debug('WsRequestor:close closing socket'); if (this.ws) {
try { this.ws.close();
if (this.ws) { this.ws.removeAllListeners();
this.ws.close();
this.ws.removeAllListeners();
}
for (const [msgid, obj] of this.messagesInFlight) {
const {timer} = obj;
clearTimeout(timer);
obj.failure(`abandoning msgid ${msgid} since we have closed the socket`);
}
this.messagesInFlight.clear();
} catch (err) {
this.logger.info({err}, 'WsRequestor: Error closing socket');
} }
} }
_connect() { _connect() {
assert(!this.ws); assert(!this.ws);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const handshakeTimeout = process.env.JAMBONES_WS_HANDSHAKE_TIMEOUT_MS ?
parseInt(process.env.JAMBONES_WS_HANDSHAKE_TIMEOUT_MS) :
1500;
let opts = { let opts = {
followRedirects: true, followRedirects: true,
maxRedirects: 2, maxRedirects: 2,
handshakeTimeout, handshakeTimeout: 1000,
maxPayload: process.env.JAMBONES_WS_MAX_PAYLOAD ? parseInt(process.env.JAMBONES_WS_MAX_PAYLOAD) : 24 * 1024, maxPayload: 8096,
}; };
if (this.username && this.password) opts = {...opts, auth: `${this.username}:${this.password}`}; if (this.username && this.password) opts = {...opts, auth: `${this.username}:${this.password}`};
this this
.once('ready', (ws) => { .once('ready', (ws) => {
this.ws = ws;
this.removeAllListeners('not-ready'); this.removeAllListeners('not-ready');
if (this.connections > 1) this.request('session:reconnect', this.url);
resolve(); resolve();
}) })
.once('not-ready', (err) => { .once('not-ready', () => {
this.removeAllListeners('ready'); this.removeAllListeners('ready');
reject(err); reject();
}); });
const ws = new Websocket(this.url, ['ws.jambonz.org'], opts); const ws = new Websocket(this.url, ['ws.jambonz.org'], opts);
this._setHandlers(ws); this._setHandlers(ws);
@@ -212,41 +149,29 @@ class WsRequestor extends BaseRequestor {
} }
_setHandlers(ws) { _setHandlers(ws) {
this.logger.debug('WsRequestor:_setHandlers');
ws ws
.once('open', this._onOpen.bind(this, ws)) .on('open', this._onOpen.bind(this, ws))
.once('close', this._onClose.bind(this)) .on('close', this._onClose.bind(this))
.on('message', this._onMessage.bind(this)) .on('message', this._onMessage.bind(this))
.once('unexpected-response', this._onUnexpectedResponse.bind(this, ws)) .on('unexpected-response', this._onUnexpectedResponse.bind(this, ws))
.on('error', this._onError.bind(this)); .on('error', this._onError.bind(this));
} }
_onError(err) { _onError(err) {
if (this.connections > 0) { this.logger.info({url: this.url, err}, 'WsRequestor:_onError');
this.logger.info({url: this.url, err}, 'WsRequestor:_onError'); if (this.connections > 0) this.emit('socket-closed');
} this.emit('not-ready');
else this.emit('not-ready', err);
} }
_onOpen(ws) { _onOpen(ws) {
this.logger.info({url: this.url}, `WsRequestor(${this.id}) - successfully connected`);
if (this.ws) this.logger.info({old_ws: this.ws._socket.address()}, 'WsRequestor:_onOpen');
assert(!this.ws); assert(!this.ws);
this.ws = ws;
this.connectInProgress = false;
this.connections++;
this.emit('ready', ws); this.emit('ready', ws);
this.logger.info({url: this.url}, 'WsRequestor - successfully connected');
} }
_onClose(code) { _onClose() {
this.logger.info(`WsRequestor(${this.id}) - closed from far end ${code}`); this.logger.info({url: this.url}, 'WsRequestor - socket closed unexpectedly from remote side');
if (this.connections > 0 && code !== 1000) { this.emit('socket-closed');
this.logger.info({url: this.url}, 'WsRequestor - socket closed unexpectedly from remote side');
this.emit('socket-closed');
}
else if (code === 1000) this.closedGracefully = true;
this.ws?.removeAllListeners();
this.ws = null;
} }
_onUnexpectedResponse(ws, req, res) { _onUnexpectedResponse(ws, req, res) {
@@ -257,24 +182,15 @@ class WsRequestor extends BaseRequestor {
statusMessage: res.statusMessage statusMessage: res.statusMessage
}, 'WsRequestor - unexpected response'); }, 'WsRequestor - unexpected response');
this.emit('connection-failure'); this.emit('connection-failure');
this.emit('not-ready', new Error(`${res.statusCode} ${res.statusMessage}`)); this.emit('not-ready');
} }
_onSocketClosed() { _onSocketClosed() {
this.ws = null; this.ws = null;
this.emit('connection-dropped'); if (this.connections > 0) {
if (this.connections > 0 && this.connections < MAX_RECONNECTS && !this.closedGracefully) { if (++this.connections < MAX_RECONNECTS) {
this.logger.debug(`WsRequestor:_onSocketClosed waiting ${this.backoffMs} to reconnect`); setImmediate(this.connect.bind(this));
setTimeout(() => { }
this.logger.debug(
{haveWs: !!this.ws, connectInProgress: this.connectInProgress},
'WsRequestor:_onSocketClosed time to reconnect');
if (!this.ws && !this.connectInProgress) {
this.connectInProgress = true;
this._connect().catch((err) => this.connectInProgress = false);
}
}, this.backoffMs);
this.backoffMs = this.backoffMs < 2000 ? this.backoffMs * 2 : (this.backoffMs + 2000);
} }
} }
@@ -288,10 +204,7 @@ class WsRequestor extends BaseRequestor {
/* messages must be JSON format */ /* messages must be JSON format */
try { try {
const obj = JSON.parse(content); const {type, msgid, command, queueCommand = false, data} = JSON.parse(content);
const {type, msgid, command, call_sid = this.call_sid, queueCommand = false, data} = obj;
//this.logger.debug({obj}, 'WsRequestor:request websocket: received');
assert.ok(type, 'type property not supplied'); assert.ok(type, 'type property not supplied');
switch (type) { switch (type) {
@@ -303,14 +216,14 @@ class WsRequestor extends BaseRequestor {
case 'command': case 'command':
assert.ok(command, 'command property not supplied'); assert.ok(command, 'command property not supplied');
assert.ok(data, 'data property not supplied'); assert.ok(data, 'data property not supplied');
this._recvCommand(msgid, command, call_sid, queueCommand, data); this._recvCommand(msgid, command, queueCommand, data);
break; break;
default: default:
assert.ok(false, `invalid type property: ${type}`); assert.ok(false, `invalid type property: ${type}`);
} }
} catch (err) { } catch (err) {
this.logger.info({err, content}, 'WsRequestor:_onMessage - invalid incoming message'); this.logger.info({err}, 'WsRequestor:_onMessage - invalid incoming message');
} }
} }
@@ -326,10 +239,10 @@ class WsRequestor extends BaseRequestor {
success && success(data); success && success(data);
} }
_recvCommand(msgid, command, call_sid, queueCommand, data) { _recvCommand(msgid, command, queueCommand, data) {
// TODO: validate command // TODO: validate command
this.logger.debug({msgid, command, call_sid, queueCommand, data}, 'received command'); this.logger.info({msgid, command, queueCommand, data}, 'received command');
this.emit('command', {msgid, command, call_sid, queueCommand, data}); this.emit('command', {msgid, command, queueCommand, data});
} }
} }

10067
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "jambonz-feature-server", "name": "jambonz-feature-server",
"version": "v0.7.8", "version": "v0.7.3",
"main": "app.js", "main": "app.js",
"engines": { "engines": {
"node": ">= 10.16.0" "node": ">= 10.16.0"
@@ -16,54 +16,50 @@
"type": "git", "type": "git",
"url": "https://github.com/jambonz/jambonz-feature-server.git" "url": "https://github.com/jambonz/jambonz-feature-server.git"
}, },
"bugs": {}, "bugs": {
"url": "https://github.com/jambonz/jambonz-feature-server/issues"
},
"scripts": { "scripts": {
"start": "node app", "start": "node app",
"test": "NODE_ENV=test JAMBONES_HOSTING=1 HTTP_POOL=1 DRACHTIO_HOST=127.0.0.1 DRACHTIO_PORT=9060 DRACHTIO_SECRET=cymru JAMBONES_MYSQL_HOST=127.0.0.1 JAMBONES_MYSQL_PORT=3360 JAMBONES_MYSQL_USER=jambones_test JAMBONES_MYSQL_PASSWORD=jambones_test JAMBONES_MYSQL_DATABASE=jambones_test JAMBONES_REDIS_HOST=127.0.0.1 JAMBONES_REDIS_PORT=16379 JAMBONES_LOGLEVEL=error ENABLE_METRICS=0 HTTP_PORT=3000 JAMBONES_SBCS=172.38.0.10 JAMBONES_FREESWITCH=127.0.0.1:8022:JambonzR0ck$:docker-host JAMBONES_TIME_SERIES_HOST=127.0.0.1 JAMBONES_NETWORK_CIDR=172.38.0.0/16 node test/ ", "test": "NODE_ENV=test JAMBONES_HOSTING=1 DRACHTIO_HOST=127.0.0.1 DRACHTIO_PORT=9060 DRACHTIO_SECRET=cymru JAMBONES_MYSQL_HOST=127.0.0.1 JAMBONES_MYSQL_PORT=3360 JAMBONES_MYSQL_USER=jambones_test JAMBONES_MYSQL_PASSWORD=jambones_test JAMBONES_MYSQL_DATABASE=jambones_test JAMBONES_REDIS_HOST=127.0.0.1 JAMBONES_REDIS_PORT=16379 JAMBONES_LOGLEVEL=debug ENABLE_METRICS=0 HTTP_PORT=3000 JAMBONES_SBCS=172.38.0.10 JAMBONES_FREESWITCH=127.0.0.1:8022:ClueCon:docker-host JAMBONES_TIME_SERIES_HOST=127.0.0.1 JAMBONES_NETWORK_CIDR=172.38.0.0/16 node test/ ",
"coverage": "./node_modules/.bin/nyc --reporter html --report-dir ./coverage npm run test", "coverage": "./node_modules/.bin/nyc --reporter html --report-dir ./coverage npm run test",
"jslint": "eslint app.js lib" "jslint": "eslint app.js lib"
}, },
"dependencies": { "dependencies": {
"@jambonz/db-helpers": "^0.7.3", "@cognigy/socket-client": "^4.5.5",
"@jambonz/db-helpers": "^0.6.16",
"@jambonz/http-health-check": "^0.0.1", "@jambonz/http-health-check": "^0.0.1",
"@jambonz/realtimedb-helpers": "^0.6.3", "@jambonz/mw-registrar": "^0.2.1",
"@jambonz/realtimedb-helpers": "^0.4.26",
"@jambonz/stats-collector": "^0.1.6", "@jambonz/stats-collector": "^0.1.6",
"@jambonz/time-series": "^0.2.5", "@jambonz/time-series": "^0.1.6",
"@opentelemetry/api": "^1.2.0", "aws-sdk": "^2.1073.0",
"@opentelemetry/exporter-jaeger": "^1.7.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.27.0",
"@opentelemetry/exporter-zipkin": "^1.7.0",
"@opentelemetry/instrumentation": "^0.27.0",
"@opentelemetry/resources": "^1.7.0",
"@opentelemetry/sdk-trace-base": "^1.7.0",
"@opentelemetry/sdk-trace-node": "^1.7.0",
"@opentelemetry/semantic-conventions": "^1.7.0",
"aws-sdk": "^2.1233.0",
"bent": "^7.3.12", "bent": "^7.3.12",
"debug": "^4.3.4", "cidr-matcher": "^2.1.1",
"debug": "^4.3.2",
"deepcopy": "^2.1.0", "deepcopy": "^2.1.0",
"drachtio-fsmrf": "^3.0.16", "drachtio-fsmrf": "^2.0.13",
"drachtio-srf": "^4.5.21", "drachtio-srf": "^4.4.61",
"express": "^4.18.2", "express": "^4.17.1",
"ip": "^1.1.8", "helmet": "^5.0.2",
"moment": "^2.29.4", "ip": "^1.1.5",
"parse-url": "^8.1.0", "moment": "^2.29.1",
"pino": "^6.14.0", "parse-url": "^5.0.7",
"sdp-transform": "^2.14.1", "pino": "^6.13.4",
"short-uuid": "^4.2.0", "short-uuid": "^4.2.0",
"to-snake-case": "^1.0.0", "to-snake-case": "^1.0.0",
"undici": "^5.11.0", "uuid": "^8.3.2",
"uuid-random": "^1.3.2", "verify-aws-sns-signature": "^0.0.6",
"verify-aws-sns-signature": "^0.1.0", "ws": "^8.5.0",
"ws": "^8.9.0",
"xml2js": "^0.4.23" "xml2js": "^0.4.23"
}, },
"devDependencies": { "devDependencies": {
"clear-module": "^4.1.2", "async": "^3.2.0",
"eslint": "^7.32.0", "clear-module": "^4.1.1",
"eslint": "^7.20.0",
"eslint-plugin-promise": "^4.3.1", "eslint-plugin-promise": "^4.3.1",
"nyc": "^15.1.0", "nyc": "^15.1.0",
"tape": "^5.6.1" "tape": "^5.2.2"
}, },
"optionalDependencies": { "optionalDependencies": {
"bufferutil": "^4.0.6", "bufferutil": "^4.0.6",

View File

@@ -1,108 +0,0 @@
const test = require('tape');
const { sippUac } = require('./sipp')('test_fs');
const bent = require('bent');
const clearModule = require('clear-module');
const {provisionCallHook} = require('./utils')
const getJSON = bent('json')
const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
function connect(connectable) {
return new Promise((resolve, reject) => {
connectable.on('connect', () => {
return resolve();
});
});
}
test('test create-call timeout', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// give UAS app time to come up
const p = sippUac('uas-timeout-cancel.xml', '172.38.0.10');
await waitFor(1000);
// GIVEN
let account_sid = '622f62e4-303a-49f2-bbe0-eb1e1714e37a';
const post = bent('http://127.0.0.1:3000/', 'POST', 'json', 201);
post('v1/createCall', {
'account_sid':account_sid,
'timeout': 1,
"call_hook": {
"url": "https://public-apps.jambonz.us/hello-world",
"method": "POST"
},
"from": "15083718299",
"to": {
"type": "phone",
"number": "15583084809"
}});
//THEN
await p;
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('test create-call call-hook basic authentication', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let from = 'call_hook_basic_authentication';
let account_sid = '622f62e4-303a-49f2-bbe0-eb1e1714e37a';
// Give UAS app time to come up
const p = sippUac('uas.xml', '172.38.0.10', from);
await waitFor(1000);
const post = bent('http://127.0.0.1:3000/', 'POST', 'json', 201);
post('v1/createCall', {
'account_sid':account_sid,
"call_hook": {
"url": "http://127.0.0.1:3100/",
"method": "POST",
"username": "username",
"password": "password"
},
"from": from,
"to": {
"type": "phone",
"number": "15583084809"
}});
let verbs = [
{
"verb": "say",
"text": "hello"
}
];
provisionCallHook(from, verbs);
//THEN
await p;
let obj = await getJSON(`http:127.0.0.1:3100/lastRequest/${from}`)
t.ok(obj.headers.Authorization = 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=',
'create-call: call-hook contains basic authentication header');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});

View File

@@ -22,17 +22,11 @@ test('creating schema', (t) => {
const google_credential = encrypt(process.env.GCP_JSON_KEY); const google_credential = encrypt(process.env.GCP_JSON_KEY);
const aws_credential = encrypt(JSON.stringify({ const aws_credential = encrypt(JSON.stringify({
access_key_id: process.env.AWS_ACCESS_KEY_ID, access_key_id: process.env.AWS_ACCESS_KEY_ID,
secret_access_key: process.env.AWS_SECRET_ACCESS_KEY, secret_access_key: process.env.AWS_SECRET_ACCESS_KEY
aws_region: process.env.AWS_REGION
}));
const microsoft_credential = encrypt(JSON.stringify({
region: process.env.MICROSOFT_REGION || 'useast',
api_key: process.env.MICROSOFT_API_KEY || '1234567890'
})); }));
const cmd = ` const cmd = `
UPDATE speech_credentials SET credential='${google_credential}' WHERE vendor='google'; UPDATE speech_credentials SET credential='${google_credential}' WHERE vendor='google';
UPDATE speech_credentials SET credential='${aws_credential}' WHERE vendor='aws'; UPDATE speech_credentials SET credential='${aws_credential}' WHERE vendor='aws';
UPDATE speech_credentials SET credential='${microsoft_credential}' WHERE vendor='microsoft';
`; `;
const path = `${__dirname}/.creds.sql`; const path = `${__dirname}/.creds.sql`;
fs.writeFileSync(path, cmd); fs.writeFileSync(path, cmd);

View File

@@ -251,7 +251,6 @@ INSERT INTO `applications` VALUES ('24d0f6af-e976-44dd-a2e8-41c7b55abe33','say a
INSERT INTO `applications` VALUES ('17461c69-56b5-4dab-ad83-1c43a0f93a3d','gather',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','10692465-a511-4277-9807-b7157e4f81e1','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US'); INSERT INTO `applications` VALUES ('17461c69-56b5-4dab-ad83-1c43a0f93a3d','gather',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','10692465-a511-4277-9807-b7157e4f81e1','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US');
INSERT INTO `applications` VALUES ('baf9213b-5556-4c20-870c-586392ed246f','transcribe',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','ecb67a8f-f7ce-4919-abf0-bbc69c1001e5','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US'); INSERT INTO `applications` VALUES ('baf9213b-5556-4c20-870c-586392ed246f','transcribe',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','ecb67a8f-f7ce-4919-abf0-bbc69c1001e5','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US');
INSERT INTO `applications` VALUES ('ae026ab5-3029-47b4-9d7c-236e3a4b4ebe','transcribe account 2',NULL,'622f62e4-303a-49f2-bbe0-eb1e1714e37a','ecb67a8f-f7ce-4919-abf0-bbc69c1001e5','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US'); INSERT INTO `applications` VALUES ('ae026ab5-3029-47b4-9d7c-236e3a4b4ebe','transcribe account 2',NULL,'622f62e4-303a-49f2-bbe0-eb1e1714e37a','ecb67a8f-f7ce-4919-abf0-bbc69c1001e5','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US');
INSERT INTO `applications` VALUES ('195d9507-6a42-46a8-825f-f009e729d023','sip info',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','c9113e7a-741f-48b9-96c1-f2f78176eeb3','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US');
/*!40000 ALTER TABLE `applications` ENABLE KEYS */; /*!40000 ALTER TABLE `applications` ENABLE KEYS */;
UNLOCK TABLES; UNLOCK TABLES;
@@ -450,7 +449,6 @@ INSERT INTO `phone_numbers` VALUES ('e686a320-0725-418f-be65-532159bdc3ed','1617
INSERT INTO `phone_numbers` VALUES ('05eeed62-b29b-4679-bf38-d7a4e318be44','16174000003','5145b436-2f38-4029-8d4c-fd8c67831c7a','bb845d4b-83a9-4cde-a6e9-50f3743bab3f','17461c69-56b5-4dab-ad83-1c43a0f93a3d', NULL); INSERT INTO `phone_numbers` VALUES ('05eeed62-b29b-4679-bf38-d7a4e318be44','16174000003','5145b436-2f38-4029-8d4c-fd8c67831c7a','bb845d4b-83a9-4cde-a6e9-50f3743bab3f','17461c69-56b5-4dab-ad83-1c43a0f93a3d', NULL);
INSERT INTO `phone_numbers` VALUES ('f3c53863-b629-4cf6-9dcb-c7fb7072314b','16174000004','5145b436-2f38-4029-8d4c-fd8c67831c7a','bb845d4b-83a9-4cde-a6e9-50f3743bab3f','baf9213b-5556-4c20-870c-586392ed246f', NULL); INSERT INTO `phone_numbers` VALUES ('f3c53863-b629-4cf6-9dcb-c7fb7072314b','16174000004','5145b436-2f38-4029-8d4c-fd8c67831c7a','bb845d4b-83a9-4cde-a6e9-50f3743bab3f','baf9213b-5556-4c20-870c-586392ed246f', NULL);
INSERT INTO `phone_numbers` VALUES ('f6416c17-829a-4f11-9c32-f0d00e4a9ae9','16174000005','5145b436-2f38-4029-8d4c-fd8c67831c7a','622f62e4-303a-49f2-bbe0-eb1e1714e37a','ae026ab5-3029-47b4-9d7c-236e3a4b4ebe', NULL); INSERT INTO `phone_numbers` VALUES ('f6416c17-829a-4f11-9c32-f0d00e4a9ae9','16174000005','5145b436-2f38-4029-8d4c-fd8c67831c7a','622f62e4-303a-49f2-bbe0-eb1e1714e37a','ae026ab5-3029-47b4-9d7c-236e3a4b4ebe', NULL);
INSERT INTO `phone_numbers` VALUES ('964d0581-9627-44cb-be20-8118050406b2','16174000006','5145b436-2f38-4029-8d4c-fd8c67831c7a','bb845d4b-83a9-4cde-a6e9-50f3743bab3f','195d9507-6a42-46a8-825f-f009e729d023', NULL);
/*!40000 ALTER TABLE `phone_numbers` ENABLE KEYS */; /*!40000 ALTER TABLE `phone_numbers` ENABLE KEYS */;
UNLOCK TABLES; UNLOCK TABLES;
@@ -614,10 +612,7 @@ CREATE TABLE `speech_credentials` (
LOCK TABLES `speech_credentials` WRITE; LOCK TABLES `speech_credentials` WRITE;
/*!40000 ALTER TABLE `speech_credentials` DISABLE KEYS */; /*!40000 ALTER TABLE `speech_credentials` DISABLE KEYS */;
INSERT INTO `speech_credentials` VALUES 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);
('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),
('2add347f-34f2-45c6-a016-f955d218ffb6',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','microsoft','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',1,1,NULL,NULL,NULL,NULL);
/*!40000 ALTER TABLE `speech_credentials` ENABLE KEYS */; /*!40000 ALTER TABLE `speech_credentials` ENABLE KEYS */;
UNLOCK TABLES; UNLOCK TABLES;
@@ -741,7 +736,6 @@ INSERT INTO `webhooks` VALUES ('c71e79db-24f2-4866-a3ee-febb0f97b341','http://12
INSERT INTO `webhooks` VALUES ('54ab0976-a6c0-45d8-89a4-d90d45bf9d96','http://127.0.0.1:3101/','POST',NULL,NULL); INSERT INTO `webhooks` VALUES ('54ab0976-a6c0-45d8-89a4-d90d45bf9d96','http://127.0.0.1:3101/','POST',NULL,NULL);
INSERT INTO `webhooks` VALUES ('10692465-a511-4277-9807-b7157e4f81e1','http://127.0.0.1:3102/','POST',NULL,NULL); INSERT INTO `webhooks` VALUES ('10692465-a511-4277-9807-b7157e4f81e1','http://127.0.0.1:3102/','POST',NULL,NULL);
INSERT INTO `webhooks` VALUES ('ecb67a8f-f7ce-4919-abf0-bbc69c1001e5','http://127.0.0.1:3103/','POST',NULL,NULL); INSERT INTO `webhooks` VALUES ('ecb67a8f-f7ce-4919-abf0-bbc69c1001e5','http://127.0.0.1:3103/','POST',NULL,NULL);
INSERT INTO `webhooks` VALUES ('c9113e7a-741f-48b9-96c1-f2f78176eeb3','http://127.0.0.1:3104/','POST',NULL,NULL);
/*!40000 ALTER TABLE `webhooks` ENABLE KEYS */; /*!40000 ALTER TABLE `webhooks` ENABLE KEYS */;
UNLOCK TABLES; UNLOCK TABLES;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;

View File

@@ -12,7 +12,7 @@ services:
platform: linux/x86_64 platform: linux/x86_64
ports: ports:
- "3360:3306" - "3360:3306"
environment: environment:
MYSQL_ALLOW_EMPTY_PASSWORD: "yes" MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
healthcheck: healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "127.0.0.1", "--protocol", "tcp"] test: ["CMD", "mysqladmin" ,"ping", "-h", "127.0.0.1", "--protocol", "tcp"]
@@ -57,7 +57,7 @@ services:
condition: service_healthy condition: service_healthy
freeswitch: freeswitch:
image: drachtio/drachtio-freeswitch-mrf:0.4.18 image: drachtio/drachtio-freeswitch-mrf:v1.10.1-full
restart: always restart: always
command: freeswitch --rtp-range-start 20000 --rtp-range-end 20100 command: freeswitch --rtp-range-start 20000 --rtp-range-end 20100
environment: environment:
@@ -68,15 +68,17 @@ services:
- /tmp:/tmp - /tmp:/tmp
- ./credentials:/opt/credentials - ./credentials:/opt/credentials
healthcheck: healthcheck:
test: ['CMD', 'fs_cli' ,'-p', 'JambonzR0ck$$', '-x', '"sofia status"'] test: ['CMD', 'fs_cli' ,'-x', '"sofia status"']
timeout: 5s timeout: 5s
retries: 15 retries: 15
networks: networks:
fs: fs:
ipv4_address: 172.38.0.51 ipv4_address: 172.38.0.51
webhook-scaffold: webhook-decline:
image: jambonz/webhook-test-scaffold:latest image: jambonz/webhook-test-scaffold:latest
environment:
APP_PATH: /tmp/decline.json
ports: ports:
- "3100:3000/tcp" - "3100:3000/tcp"
volumes: volumes:
@@ -85,6 +87,42 @@ services:
fs: fs:
ipv4_address: 172.38.0.60 ipv4_address: 172.38.0.60
webhook-say:
image: jambonz/webhook-test-scaffold:latest
environment:
APP_PATH: /tmp/say.json
ports:
- "3101:3000/tcp"
volumes:
- ./test-apps:/tmp
networks:
fs:
ipv4_address: 172.38.0.61
webhook-gather:
image: jambonz/webhook-test-scaffold:latest
environment:
APP_PATH: /tmp/gather.json
ports:
- "3102:3000/tcp"
volumes:
- ./test-apps:/tmp
networks:
fs:
ipv4_address: 172.38.0.62
webhook-transcribe:
image: jambonz/webhook-test-scaffold:latest
environment:
APP_PATH: /tmp/transcribe.json
ports:
- "3103:3000/tcp"
volumes:
- ./test-apps:/tmp
networks:
fs:
ipv4_address: 172.38.0.63
influxdb: influxdb:
image: influxdb:1.8 image: influxdb:1.8
ports: ports:

View File

@@ -1,5 +1,6 @@
const test = require('tape') ; const test = require('tape') ;
const exec = require('child_process').exec ; const exec = require('child_process').exec ;
const async = require('async');
test('starting docker network..takes a bit for mysql and freeswitch to come up..patience..', (t) => { test('starting docker network..takes a bit for mysql and freeswitch to come up..patience..', (t) => {
exec(`docker-compose -f ${__dirname}/docker-compose-testbed.yaml up -d`, (err, stdout, stderr) => { exec(`docker-compose -f ${__dirname}/docker-compose-testbed.yaml up -d`, (err, stdout, stderr) => {

View File

@@ -3,7 +3,6 @@ const { sippUac } = require('./sipp')('test_fs');
const bent = require('bent'); const bent = require('bent');
const getJSON = bent('json') const getJSON = bent('json')
const clearModule = require('clear-module'); const clearModule = require('clear-module');
const {provisionCallHook} = require('./utils')
process.on('unhandledRejection', (reason, p) => { process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
@@ -17,196 +16,16 @@ function connect(connectable) {
}); });
} }
test('\'gather\' test - google', async(t) => { test('\'gather\' and \'transcribe\' tests', async(t) => {
if (!process.env.GCP_JSON_KEY) {
t.pass('skipping google tests');
return t.end();
}
clearModule.all(); clearModule.all();
const {srf, disconnect} = require('../app'); const {srf, disconnect} = require('../app');
try { try {
await connect(srf); await connect(srf);
// GIVEN await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10');
let verbs = [ let obj = await getJSON('http://127.0.0.1:3102/actionHook');
{ t.ok(obj.speech.alternatives[0].transcript = 'I\'d like to speak to customer support',
"verb": "gather", 'gather: succeeds when using account credentials');
"input": ["speech"],
"recognizer": {
"vendor": "google",
"hints": ["customer support", "sales", "human resources", "HR"]
},
"timeout": 10,
"actionHook": "/actionHook"
}
];
let from = "gather_success";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
//console.log(JSON.stringify(obj));
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
'gather: succeeds when using google credentials');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'gather\' test - default (google)', async(t) => {
if (!process.env.GCP_JSON_KEY) {
t.pass('skipping google tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "gather",
"input": ["speech"],
"timeout": 10,
"actionHook": "/actionHook"
}
];
let from = "gather_success";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
//console.log(JSON.stringify(obj));
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase() === 'i\'d like to speak to customer support',
'gather: succeeds when using default (google) credentials');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'gather\' test - microsoft', async(t) => {
if (!process.env.MICROSOFT_REGION || !process.env.MICROSOFT_API_KEY) {
t.pass('skipping microsoft tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "gather",
"input": ["speech"],
"recognizer": {
"vendor": "microsoft",
"hints": ["customer support", "sales", "human resources", "HR"]
},
"timeout": 10,
"actionHook": "/actionHook"
}
];
let from = "gather_success";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
//console.log(JSON.stringify(obj));
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
'gather: succeeds when using microsoft credentials');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'gather\' test - aws', async(t) => {
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) {
t.pass('skipping aws tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "gather",
"input": ["speech"],
"recognizer": {
"vendor": "aws",
"hints": ["customer support", "sales", "human resources", "HR"]
},
"timeout": 10,
"actionHook": "/actionHook"
}
];
let from = "gather_success";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
//console.log(JSON.stringify(obj));
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
'gather: succeeds when using aws credentials');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'gather\' test - deepgram', async(t) => {
if (!process.env.DEEPGRAM_API_KEY ) {
t.pass('skipping deepgram tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "gather",
"input": ["speech"],
"recognizer": {
"vendor": "deepgram",
"hints": ["customer support", "sales", "human resources", "HR"],
"deepgramOptions": {
"apiKey": process.env.DEEPGRAM_API_KEY
}
},
"timeout": 10,
"actionHook": "/actionHook"
}
];
let from = "gather_success";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
//console.log(JSON.stringify(obj));
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
'gather: succeeds when using deepgram credentials');
disconnect(); disconnect();
} catch (err) { } catch (err) {

View File

@@ -5,9 +5,5 @@ require('./account-validation-tests');
require('./webhooks-tests'); require('./webhooks-tests');
require('./say-tests'); require('./say-tests');
require('./gather-tests'); require('./gather-tests');
require('./transcribe-tests');
require('./sip-request-tests');
require('./create-call-test');
require('./play-tests');
require('./remove-test-db'); require('./remove-test-db');
require('./docker_stop'); require('./docker_stop');

View File

@@ -1,198 +0,0 @@
const test = require('tape');
const { sippUac } = require('./sipp')('test_fs');
const clearModule = require('clear-module');
const {provisionCallHook, provisionCustomHook} = require('./utils')
const bent = require('bent');
const getJSON = bent('json')
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
function connect(connectable) {
return new Promise((resolve, reject) => {
connectable.on('connect', () => {
return resolve();
});
});
}
test('\'play\' tests single link in plain text', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
const verbs = [
{
verb: 'play',
url: 'https://example.com/example.mp3'
}
];
const from = 'play_single_link';
provisionCallHook(from, verbs)
// THEN
await sippUac('uac-success-received-bye.xml', '172.38.0.10', from);
t.pass('play: succeeds when using single link');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'play\' tests multi links in array', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
const verbs = [
{
verb: 'play',
url: ['https://example.com/example.mp3', 'https://example.com/example.mp3']
}
];
const from = 'play_multi_links_in_array';
provisionCallHook(from, verbs)
// THEN
await sippUac('uac-success-received-bye.xml', '172.38.0.10', from);
t.pass('play: succeeds when using links in array');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'play\' tests single link in conference', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
const from = 'play_single_link_in_conference';
const waitHookVerbs = [
{
verb: 'play',
url: 'https://example.com/example.mp3'
}
];
const verbs = [
{
verb: 'conference',
name: `${from}`,
beep: true,
"startConferenceOnEnter": false,
waitHook: `/customHook`
}
];
provisionCustomHook(from, waitHookVerbs)
provisionCallHook(from, verbs)
// THEN
await sippUac('uac-success-send-bye.xml', '172.38.0.10', from);
t.pass('play: succeeds when using in conference as single link');
// Make sure that waitHook is called and success
await getJSON(`http:127.0.0.1:3100/lastRequest/${from}_customHook`)
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'play\' tests multi links in array in conference', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
const from = 'play_multi_links_in_conference';
const waitHookVerbs = [
{
verb: 'play',
url: ['https://example.com/example.mp3', 'https://example.com/example.mp3']
}
];
const verbs = [
{
verb: 'conference',
name: `${from}`,
beep: true,
"startConferenceOnEnter": false,
waitHook: `/customHook`
}
];
provisionCustomHook(from, waitHookVerbs)
provisionCallHook(from, verbs)
// THEN
await sippUac('uac-success-send-bye.xml', '172.38.0.10', from);
t.pass('play: succeeds when using in conference with multi links');
// Make sure that waitHook is called and success
await getJSON(`http:127.0.0.1:3100/lastRequest/${from}_customHook`)
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'play\' tests with seekOffset and actionHook', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
const verbs = [
{
verb: 'play',
url: 'silence_stream://5000',
seekOffset: 8000,
timeoutSecs: 2,
actionHook: '/customHook'
}
];
const waitHookVerbs = [];
const from = 'play_action_hook';
provisionCallHook(from, verbs)
provisionCustomHook(from, waitHookVerbs)
// THEN
await sippUac('uac-success-received-bye.xml', '172.38.0.10', from);
t.pass('play: succeeds');
const obj = await getJSON(`http:127.0.0.1:3100/lastRequest/${from}_customHook`)
t.ok(obj.body.reason === "playCompleted", "play: actionHook success received")
t.ok(obj.body.playback_seconds === "2", "playback_seconds: actionHook success received")
t.ok(obj.body.playback_milliseconds === "2048", "playback_milliseconds: actionHook success received")
t.ok(obj.body.playback_last_offset_pos === "16000", "playback_last_offset_pos: actionHook success received")
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});

View File

@@ -1,7 +1,6 @@
const test = require('tape'); const test = require('tape');
const { sippUac } = require('./sipp')('test_fs'); const { sippUac } = require('./sipp')('test_fs');
const clearModule = require('clear-module'); const clearModule = require('clear-module');
const {provisionCallHook} = require('./utils')
process.on('unhandledRejection', (reason, p) => { process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
@@ -21,21 +20,9 @@ test('\'say\' tests', async(t) => {
try { try {
await connect(srf); await connect(srf);
await sippUac('uac-say-account-creds-success.xml', '172.38.0.10');
// GIVEN
const verbs = [
{
verb: 'say',
text: 'hello'
}
];
const from = 'say_test_success';
provisionCallHook(from, verbs)
// THEN
await sippUac('uac-success-received-bye.xml', '172.38.0.10', from);
t.pass('say: succeeds when using using account credentials'); t.pass('say: succeeds when using using account credentials');
disconnect(); disconnect();
} catch (err) { } catch (err) {
console.log(`error received: ${err}`); console.log(`error received: ${err}`);

View File

@@ -7,7 +7,7 @@
INVITE sip:16174000000@[remote_ip]:[remote_port] SIP/2.0 INVITE sip:16174000000@[remote_ip]:[remote_port] SIP/2.0
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch] Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number] From: sipp <sip:sipp@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
To: <sip:16174000000@[remote_ip]:[remote_port]> To: <sip:16174000000@[remote_ip]:[remote_port]>
Call-ID: [call_id] Call-ID: [call_id]
CSeq: 1 INVITE CSeq: 1 INVITE
@@ -41,7 +41,7 @@
ACK sip:16174000000@[remote_ip]:[remote_port] SIP/2.0 ACK sip:16174000000@[remote_ip]:[remote_port] SIP/2.0
[last_Via] [last_Via]
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number] From: sipp <sip:sipp@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
To: <sip:16174000000@[remote_ip]:[remote_port]>[peer_tag_param] To: <sip:16174000000@[remote_ip]:[remote_port]>[peer_tag_param]
Call-ID: [call_id] Call-ID: [call_id]
CSeq: 1 ACK CSeq: 1 ACK
@@ -53,3 +53,4 @@
</send> </send>
</scenario> </scenario>

View File

@@ -8,13 +8,13 @@
<send retrans="500"> <send retrans="500">
<![CDATA[ <![CDATA[
INVITE sip:[to]@[remote_ip]:[remote_port] SIP/2.0 INVITE sip:16174000003@[remote_ip]:[remote_port] SIP/2.0
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch] Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number] From: sipp <sip:sipp@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
To: <sip:[to]@[remote_ip]:[remote_port]> To: <sip:16174000003@[remote_ip]:[remote_port]>
Call-ID: [call_id] Call-ID: [call_id]
CSeq: 1 INVITE CSeq: 1 INVITE
Contact: sip:[from]@[local_ip]:[local_port] Contact: sip:sipp@[local_ip]:[local_port]
Max-Forwards: 70 Max-Forwards: 70
X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f
Subject: uac-gather-account-creds-success Subject: uac-gather-account-creds-success
@@ -53,13 +53,13 @@
<send> <send>
<![CDATA[ <![CDATA[
ACK sip:[to]@[remote_ip]:[remote_port] SIP/2.0 ACK sip:16174000003@[remote_ip]:[remote_port] SIP/2.0
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch] Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number] From: sipp <sip:sipp@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
To: [to] <sip:[to]@[remote_ip]:[remote_port]>[peer_tag_param] To: 16174000003 <sip:16174000003@[remote_ip]:[remote_port]>[peer_tag_param]
Call-ID: [call_id] Call-ID: [call_id]
CSeq: 1 ACK CSeq: 1 ACK
Contact: sip:[from]@[local_ip]:[local_port] Contact: sip:sipp@[local_ip]:[local_port]
Max-Forwards: 70 Max-Forwards: 70
Subject: uac-gather-account-creds-success Subject: uac-gather-account-creds-success
Content-Length: 0 Content-Length: 0

View File

@@ -1,71 +0,0 @@
<?xml version="1.0" encoding="ISO-8859-1" ?>
<!DOCTYPE scenario SYSTEM "sipp.dtd">
<scenario name="Basic Sipstone UAC">
<!-- In client mode (sipp placing calls), the Call-ID MUST be -->
<!-- generated by sipp. To do so, use [call_id] keyword. -->
<send retrans="500">
<![CDATA[
INVITE sip:[to]@[remote_ip]:[remote_port] SIP/2.0
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
To: <sip:[to]@[remote_ip]:[remote_port]>
Call-ID: [call_id]
CSeq: 1 INVITE
Contact: sip:[from]@[local_ip]:[local_port]
Max-Forwards: 70
X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f
Subject: uac-say
Content-Type: application/sdp
Content-Length: [len]
v=0
o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip]
s=-
c=IN IP[media_ip_type] [media_ip]
t=0 0
m=audio [media_port] RTP/AVP 0
a=rtpmap:0 PCMU/8000
]]>
</send>
<recv response="100"
optional="true">
</recv>
<recv response="180" optional="true">
</recv>
<recv response="183" optional="true">
</recv>
<!-- By adding rrs="true" (Record Route Sets), the route sets -->
<!-- are saved and used for following messages sent. Useful to test -->
<!-- against stateful SIP proxies/B2BUAs. -->
<recv response="480" rtd="true">
</recv>
<!-- Packet lost can be simulated in any send/recv message by -->
<!-- by adding the 'lost = "10"'. Value can be [1-100] percent. -->
<send>
<![CDATA[
ACK sip:[to]@[remote_ip]:[remote_port] SIP/2.0
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
To: [to] <sip:[to]@[remote_ip]:[remote_port]>[peer_tag_param]
Call-ID: [call_id]
CSeq: 1 ACK
Contact: sip:[from]@[local_ip]:[local_port]
Max-Forwards: 70
Subject: uac-say
Content-Length: 0
]]>
</send>
</scenario>

View File

@@ -8,13 +8,13 @@
<send retrans="500"> <send retrans="500">
<![CDATA[ <![CDATA[
INVITE sip:[to]@[remote_ip]:[remote_port] SIP/2.0 INVITE sip:16174000001@[remote_ip]:[remote_port] SIP/2.0
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch] Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number] From: sipp <sip:sipp@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
To: <sip:[to]@[remote_ip]:[remote_port]> To: <sip:16174000001@[remote_ip]:[remote_port]>
Call-ID: [call_id] Call-ID: [call_id]
CSeq: 1 INVITE CSeq: 1 INVITE
Contact: sip:[from]@[local_ip]:[local_port] Contact: sip:sipp@[local_ip]:[local_port]
Max-Forwards: 70 Max-Forwards: 70
X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f
Subject: uac-say Subject: uac-say
@@ -53,13 +53,13 @@
<send> <send>
<![CDATA[ <![CDATA[
ACK sip:[to]@[remote_ip]:[remote_port] SIP/2.0 ACK sip:16174000001@[remote_ip]:[remote_port] SIP/2.0
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch] Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number] From: sipp <sip:sipp@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
To: [to] <sip:[to]@[remote_ip]:[remote_port]>[peer_tag_param] To: 16174000001 <sip:16174000001@[remote_ip]:[remote_port]>[peer_tag_param]
Call-ID: [call_id] Call-ID: [call_id]
CSeq: 1 ACK CSeq: 1 ACK
Contact: sip:[from]@[local_ip]:[local_port] Contact: sip:sipp@[local_ip]:[local_port]
Max-Forwards: 70 Max-Forwards: 70
Subject: uac-say Subject: uac-say
Content-Length: 0 Content-Length: 0

View File

@@ -1,107 +0,0 @@
<?xml version="1.0" encoding="ISO-8859-1" ?>
<!DOCTYPE scenario SYSTEM "sipp.dtd">
<scenario name="Basic Sipstone UAC">
<!-- In client mode (sipp placing calls), the Call-ID MUST be -->
<!-- generated by sipp. To do so, use [call_id] keyword. -->
<send retrans="500">
<![CDATA[
INVITE sip:[to]@[remote_ip]:[remote_port] SIP/2.0
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
To: <sip:[to]@[remote_ip]:[remote_port]>
Call-ID: [call_id]
CSeq: 1 INVITE
Contact: sip:[from]@[local_ip]:[local_port]
Max-Forwards: 70
X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f
Subject: uac-gather-account-creds-success
Content-Type: application/sdp
Content-Length: [len]
v=0
o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip]
s=-
c=IN IP[media_ip_type] [media_ip]
t=0 0
m=audio [media_port] RTP/AVP 0
a=rtpmap:0 PCMU/8000
]]>
</send>
<recv response="100"
optional="true">
</recv>
<recv response="180" optional="true">
</recv>
<recv response="183" optional="true">
</recv>
<!-- By adding rrs="true" (Record Route Sets), the route sets -->
<!-- are saved and used for following messages sent. Useful to test -->
<!-- against stateful SIP proxies/B2BUAs. -->
<recv response="200" rtd="true">
</recv>
<!-- Packet lost can be simulated in any send/recv message by -->
<!-- by adding the 'lost = "10"'. Value can be [1-100] percent. -->
<send>
<![CDATA[
ACK sip:[to]@[remote_ip]:[remote_port] SIP/2.0
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
To: [to] <sip:[to]@[remote_ip]:[remote_port]>[peer_tag_param]
Call-ID: [call_id]
CSeq: 1 ACK
Contact: sip:[from]@[local_ip]:[local_port]
Max-Forwards: 70
Subject: uac-gather-account-creds-success
Content-Length: 0
]]>
</send>
<recv request="INFO">
</recv>
<send>
<![CDATA[
SIP/2.0 200 OK
[last_Via:]
[last_From:]
[last_To:]
[last_Call-ID:]
[last_CSeq:]
Contact: <sip:[local_ip]:[local_port];transport=[transport]>
Content-Length: 0
]]>
</send>
<recv request="BYE">
</recv>
<send>
<![CDATA[
SIP/2.0 200 OK
[last_Via:]
[last_From:]
[last_To:]
[last_Call-ID:]
[last_CSeq:]
Contact: <sip:[local_ip]:[local_port];transport=[transport]>
Content-Length: 0
]]>
</send>
</scenario>

View File

@@ -1,92 +0,0 @@
<?xml version="1.0" encoding="ISO-8859-1" ?>
<!DOCTYPE scenario SYSTEM "sipp.dtd">
<scenario name="Basic Sipstone UAC">
<!-- In client mode (sipp placing calls), the Call-ID MUST be -->
<!-- generated by sipp. To do so, use [call_id] keyword. -->
<send retrans="500">
<![CDATA[
INVITE sip:[to]@[remote_ip]:[remote_port] SIP/2.0
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
To: <sip:[to]@[remote_ip]:[remote_port]>
Call-ID: [call_id]
CSeq: 1 INVITE
Contact: sip:[from]@[local_ip]:[local_port]
Max-Forwards: 70
X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f
Subject: uac-say
Content-Type: application/sdp
Content-Length: [len]
v=0
o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip]
s=-
c=IN IP[media_ip_type] [media_ip]
t=0 0
m=audio [media_port] RTP/AVP 0
a=rtpmap:0 PCMU/8000
]]>
</send>
<recv response="100"
optional="true">
</recv>
<recv response="180" optional="true">
</recv>
<recv response="183" optional="true">
</recv>
<!-- By adding rrs="true" (Record Route Sets), the route sets -->
<!-- are saved and used for following messages sent. Useful to test -->
<!-- against stateful SIP proxies/B2BUAs. -->
<recv response="200" rtd="true">
</recv>
<!-- Packet lost can be simulated in any send/recv message by -->
<!-- by adding the 'lost = "10"'. Value can be [1-100] percent. -->
<send>
<![CDATA[
ACK sip:[to]@[remote_ip]:[remote_port] SIP/2.0
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
To: [to] <sip:[to]@[remote_ip]:[remote_port]>[peer_tag_param]
Call-ID: [call_id]
CSeq: 1 ACK
Contact: sip:[from]@[local_ip]:[local_port]
Max-Forwards: 70
Subject: uac-say
Content-Length: 0
]]>
</send>
<pause milliseconds="3000"/>
<!-- The 'crlf' option inserts a blank line in the statistics report. -->
<send retrans="500">
<![CDATA[
BYE sip:sip:[to]@[remote_ip]:[remote_port] SIP/2.0
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
To: [to] <sip:[to]@[remote_ip]:[remote_port]>[peer_tag_param]
Call-ID: [call_id]
CSeq: 2 BYE
Max-Forwards: 70
Content-Length: 0
]]>
</send>
<recv response="200" crlf="true">
</recv>
</scenario>

View File

@@ -1,99 +0,0 @@
<?xml version="1.0" encoding="ISO-8859-1" ?>
<!DOCTYPE scenario SYSTEM "sipp.dtd">
<!-- This program is free software; you can redistribute it and/or -->
<!-- modify it under the terms of the GNU General Public License as -->
<!-- published by the Free Software Foundation; either version 2 of the -->
<!-- License, or (at your option) any later version. -->
<!-- -->
<!-- This program is distributed in the hope that it will be useful, -->
<!-- but WITHOUT ANY WARRANTY; without even the implied warranty of -->
<!-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -->
<!-- GNU General Public License for more details. -->
<!-- -->
<!-- You should have received a copy of the GNU General Public License -->
<!-- along with this program; if not, write to the -->
<!-- Free Software Foundation, Inc., -->
<!-- 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -->
<!-- -->
<!-- Sipp default 'uas' scenario. -->
<!-- -->
<scenario name="UAS Timeout Receive Cancel">
<!-- By adding rrs="true" (Record Route Sets), the route sets -->
<!-- are saved and used for following messages sent. Useful to test -->
<!-- against stateful SIP proxies/B2BUAs. -->
<recv request="INVITE" crlf="true">
<action>
<ereg regexp=".*" search_in="hdr" header="Subject:" assign_to="1" />
<ereg regexp=".*" search_in="hdr" header="CSeq:" check_it="false" assign_to="2"/>
</action>
</recv>
<!-- The '[last_*]' keyword is replaced automatically by the -->
<!-- specified header if it was present in the last message received -->
<!-- (except if it was a retransmission). If the header was not -->
<!-- present or if no message has been received, the '[last_*]' -->
<!-- keyword is discarded, and all bytes until the end of the line -->
<!-- are also discarded. -->
<!-- -->
<!-- If the specified header was present several times in the -->
<!-- message, all occurrences are concatenated (CRLF separated) -->
<!-- to be used in place of the '[last_*]' keyword. -->
<send>
<![CDATA[
SIP/2.0 180 Ringing
[last_Via:]
[last_From:]
[last_To:];tag=[pid]SIPpTag01[call_number]
[last_Call-ID:]
[last_CSeq:]
[last_Record-Route:]
Subject:[$1]
Content-Length: 0
]]>
</send>
<recv request="CANCEL" >
</recv>
<send>
<![CDATA[
SIP/2.0 200 OK
[last_Via:]
[last_From:]
[last_To:];tag=[pid]SIPpTag01[call_number]
[last_Call-ID:]
[last_CSeq:]
Subject:[$1]
Contact: <sip:[local_ip]:[local_port];transport=[transport]>
]]>
</send>
<send>
<![CDATA[
SIP/2.0 487 Request Terminated
[last_Via:]
[last_From:]
[last_To:];tag=[pid]SIPpTag01[call_number]
[last_Call-ID:]
CSeq: [$2]
[last_Record-Route:]
Subject:[$1]
Contact: <sip:[local_ip]:[local_port];transport=[transport]>
]]>
</send>
<recv request="ACK"
rtd="true"
crlf="true">
</recv>
</scenario>

View File

@@ -1,55 +0,0 @@
const test = require('tape');
const { sippUac } = require('./sipp')('test_fs');
const bent = require('bent');
const getJSON = bent('json')
const clearModule = require('clear-module');
const {provisionCallHook} = require('./utils')
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
function connect(connectable) {
return new Promise((resolve, reject) => {
connectable.on('connect', () => {
return resolve();
});
});
}
test('sending SIP in-dialog requests tests', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
//GIVEN
let verbs = [
{
"verb": "say",
"text": "hello"
},
{
"verb": "sip:request",
"method": "info",
"headers": {
"Content-Type": "application/text"
},
"body": "here I am ",
"actionHook": "/actionHook"
}
];
let from = "sip_indialog_test";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-send-info-during-dialog.xml', '172.38.0.10', from);
const obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
t.ok(obj.body.sip_status === 200, 'successfully sent SIP INFO');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});

View File

@@ -1,5 +1,7 @@
const test = require('tape'); const test = require('blue-tape');
const { sippUac } = require('./sipp')('test_sbc-inbound'); const { output, sippUac } = require('./sipp')('test_sbc-inbound');
const debug = require('debug')('drachtio:sbc-inbound');
const clearModule = require('clear-module');
process.on('unhandledRejection', (reason, p) => { process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);

View File

@@ -24,7 +24,7 @@ obj.output = () => {
return output; return output;
}; };
obj.sippUac = (file, bindAddress, from='sipp', to='16174000000') => { obj.sippUac = (file, bindAddress) => {
const cmd = 'docker'; const cmd = 'docker';
const args = [ const args = [
'run', '-t', '--rm', '--net', `${network}`, 'run', '-t', '--rm', '--net', `${network}`,
@@ -34,14 +34,12 @@ obj.sippUac = (file, bindAddress, from='sipp', to='16174000000') => {
'-sleep', '250ms', '-sleep', '250ms',
'-nostdin', '-nostdin',
'-cid_str', `%u-%p@%s-${idx++}`, '-cid_str', `%u-%p@%s-${idx++}`,
'172.38.0.50', '172.38.0.50'
'-key','from', from,
'-key','to', to, '-trace_msg'
]; ];
if (bindAddress) args.splice(5, 0, '--ip', bindAddress); if (bindAddress) args.splice(5, 0, '--ip', bindAddress);
//console.log(args.join(' ')); console.log(args.join(' '));
clearOutput(); clearOutput();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -63,7 +61,7 @@ obj.sippUac = (file, bindAddress, from='sipp', to='16174000000') => {
addOutput(data.toString()); addOutput(data.toString());
}); });
child_process.stdout.on('data', (data) => { child_process.stdout.on('data', (data) => {
// console.log(`stdout: ${data}`); //console.log(`stdout: ${data}`);
addOutput(data.toString()); addOutput(data.toString());
}); });
}); });

View File

@@ -1,15 +0,0 @@
[
{
"verb": "say",
"text": "hello"
},
{
"verb": "sip:request",
"method": "info",
"headers": {
"Content-Type": "application/text"
},
"body": "here I am ",
"actionHook": "/actionHook"
}
]

View File

@@ -1,169 +0,0 @@
const test = require('tape');
const { sippUac } = require('./sipp')('test_fs');
const bent = require('bent');
const getJSON = bent('json')
const clearModule = require('clear-module');
const {provisionCallHook} = require('./utils')
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
function connect(connectable) {
return new Promise((resolve, reject) => {
connectable.on('connect', () => {
return resolve();
});
});
}
test('\'transcribe\' test - google', async(t) => {
if (!process.env.GCP_JSON_KEY) {
t.pass('skipping google tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "transcribe",
"recognizer": {
"vendor": "google",
"hints": ["customer support", "sales", "human resources", "HR"]
},
"transcriptionHook": "/transcriptionHook"
}
];
let from = "gather_success";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
'transcribe: succeeds when using google credentials');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'transcribe\' test - microsoft', async(t) => {
if (!process.env.MICROSOFT_REGION || !process.env.MICROSOFT_API_KEY) {
t.pass('skipping microsoft tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "transcribe",
"recognizer": {
"vendor": "microsoft",
"hints": ["customer support", "sales", "human resources", "HR"]
},
"transcriptionHook": "/transcriptionHook"
}
];
let from = "gather_success";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
'transcribe: succeeds when using microsoft credentials');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'transcribe\' test - aws', async(t) => {
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) {
t.pass('skipping aws tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "transcribe",
"recognizer": {
"vendor": "aws",
"hints": ["customer support", "sales", "human resources", "HR"]
},
"transcriptionHook": "/transcriptionHook"
}
];
let from = "gather_success";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
'transcribe: succeeds when using aws credentials');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'transcribe\' test - deepgram', async(t) => {
if (!process.env.DEEPGRAM_API_KEY ) {
t.pass('skipping deepgram tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "transcribe",
"recognizer": {
"vendor": "aws",
"hints": ["customer support", "sales", "human resources", "HR"],
"deepgramOptions": {
"apiKey": process.env.DEEPGRAM_API_KEY
}
},
"transcriptionHook": "/transcriptionHook"
}
];
let from = "gather_success";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
'transcribe: succeeds when using deepgram credentials');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});

View File

@@ -1,27 +0,0 @@
const bent = require('bent');
/*
* phoneNumber: 16174000000
* Hook endpoints http://127.0.0.1:3100/
* The function help testcase to register desired jambonz json response for an application call
* When a call has From number match the registered hook event, the desired jambonz json will be responded.
*/
const provisionCallHook = (from, verbs) => {
const mapping = {
from,
data: JSON.stringify(verbs)
};
const post = bent('http://127.0.0.1:3100', 'POST', 'string', 200);
post('/appMapping', mapping);
}
const provisionCustomHook = (from, verbs) => {
const mapping = {
from,
data: JSON.stringify(verbs)
};
const post = bent('http://127.0.0.1:3100', 'POST', 'string', 200);
post(`/customHookMapping`, mapping);
}
module.exports = { provisionCallHook, provisionCustomHook}

View File

@@ -1,23 +1,15 @@
FROM --platform=linux/amd64 node:18.6.0-alpine as base FROM node:alpine as builder
RUN apk update && apk add --no-cache python make g++
RUN apk --update --no-cache add --virtual .builds-deps build-base python3
WORKDIR /opt/app/ WORKDIR /opt/app/
COPY package.json ./
RUN npm install
RUN npm prune
FROM base as build FROM node:alpine as webapp
RUN apk add curl
COPY package.json package-lock.json ./ WORKDIR /opt/app
COPY . /opt/app
RUN npm ci COPY --from=builder /opt/app/node_modules ./node_modules
COPY ./entrypoint.sh /entrypoint.sh
COPY . . RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
FROM base
COPY --from=build /opt/app /opt/app/
ARG NODE_ENV
ENV NODE_ENV $NODE_ENV
CMD [ "node", "app.js" ]

View File

@@ -3,126 +3,47 @@ const fs = require('fs');
const express = require('express'); const express = require('express');
const app = express(); const app = express();
const listenPort = process.env.HTTP_PORT || 3000; const listenPort = process.env.HTTP_PORT || 3000;
let json_mapping = new Map(); let lastAction, lastEvent;
let hook_mapping = new Map();
assert.ok(process.env.APP_PATH, 'env var APP_PATH is required');
app.listen(listenPort, () => { app.listen(listenPort, () => {
console.log(`sample jambones app server listening on ${listenPort}`); console.log(`sample jambones app server listening on ${listenPort}`);
}); });
const applicationData = JSON.parse(fs.readFileSync(process.env.APP_PATH));
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
app.use(express.json()); app.use(express.json());
/*
* Markup language
*/
app.all('/', (req, res) => { app.all('/', (req, res) => {
console.log(req.body, 'POST /'); console.log(applicationData, `${req.method} /`);
const key = req.body.from return res.json(applicationData);
return getJsonFromMap(key, req, res);
}); });
app.post('/appMapping', (req, res) => {
console.log(req.body, 'POST /appMapping');
json_mapping.set(req.body.from, req.body.data);
return res.sendStatus(200);
});
/*
* Status Callback
*/
app.post('/callStatus', (req, res) => { app.post('/callStatus', (req, res) => {
console.log({payload: req.body}, 'POST /callStatus'); console.log({payload: req.body}, 'POST /callStatus');
let key = req.body.from + "_callStatus"
addRequestToMap(key, req, hook_mapping);
return res.sendStatus(200); return res.sendStatus(200);
}); });
/*
* transcriptionHook
*/
app.post('/transcriptionHook', (req, res) => {
console.log({payload: req.body}, 'POST /transcriptionHook');
let key = req.body.from + "_actionHook"
addRequestToMap(key, req, hook_mapping);
return res.json([{"verb": "hangup"}]);
});
/*
* actionHook
*/
app.post('/actionHook', (req, res) => { app.post('/actionHook', (req, res) => {
console.log({payload: req.body}, 'POST /actionHook'); console.log({payload: req.body}, 'POST /actionHook');
let key = req.body.from + "_actionHook" lastAction = req.body;
addRequestToMap(key, req, hook_mapping);
return res.sendStatus(200); return res.sendStatus(200);
}); });
/* app.get('/actionHook', (req, res) => {
* customHook console.log({payload: lastAction}, 'GET /actionHook');
* For the hook to return return res.json(lastAction);
*/
app.all('/customHook', (req, res) => {
let key = `${req.body.from}_customHook`;;
console.log(req.body, `POST /customHook`);
return getJsonFromMap(key, req, res);
}); });
app.post('/customHookMapping', (req, res) => { app.post('/eventHook', (req, res) => {
let key = `${req.body.from}_customHook`; console.log({payload: req.body}, 'POST /eventHook');
console.log(req.body, `POST /customHookMapping`); lastEvent = req.body;
json_mapping.set(key, req.body.data);
return res.sendStatus(200); return res.sendStatus(200);
}); });
// Fetch Requests app.get('/eventHook', (req, res) => {
app.get('/requests/:key', (req, res) => { console.log({payload: lastEvent}, 'GET /eventHook');
let key = req.params.key; return res.json(lastEvent);
if (hook_mapping.has(key)) { });
return res.json(hook_mapping.get(key));
} else {
return res.sendStatus(404);
}
})
app.get('/lastRequest/:key', (req, res) => {
let key = req.params.key;
if (hook_mapping.has(key)) {
let requests = hook_mapping.get(key);
return res.json(requests[requests.length - 1]);
} else {
return res.sendStatus(404);
}
})
/*
* private function
*/
function getJsonFromMap(key, req, res) {
if (!json_mapping.has(key)) return res.sendStatus(404);
const retData = JSON.parse(json_mapping.get(key));
console.log(retData, ` Response to ${req.method} ${req.url}`);
addRequestToMap(key, req, hook_mapping);
return res.json(retData);
}
function addRequestToMap(key, req, map) {
let headers = new Map()
for(let i = 0; i < req.rawHeaders.length; i++) {
if (i % 2 === 0) {
headers.set(req.rawHeaders[i], req.rawHeaders[i + 1])
}
}
let request = {
'url': req.url,
'headers': Object.fromEntries(headers),
'body': req.body
}
if (map.has(key)) {
map.get(key).push(request);
} else {
map.set(key, [request]);
}
}

View File

@@ -0,0 +1,3 @@
#!/bin/sh
cd /opt/app/
npm start

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,6 @@
"author": "Dave Horton", "author": "Dave Horton",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"express": "^4.18.2" "express": "^4.17.1"
} }
} }

View File

@@ -1,15 +1,6 @@
const test = require('tape'); const test = require('tape');
const { sippUac } = require('./sipp')('test_fs'); const { sippUac } = require('./sipp')('test_fs');
const clearModule = require('clear-module'); const clearModule = require('clear-module');
const {provisionCallHook} = require('./utils')
const opts = {
timestamp: () => {return `, "time": "${new Date().toISOString()}"`;},
level: process.env.JAMBONES_LOGLEVEL || 'info'
};
const logger = require('pino')(opts);
const { queryAlerts } = require('@jambonz/time-series')(
logger, process.env.JAMBONES_TIME_SERIES_HOST
);
process.on('unhandledRejection', (reason, p) => { process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
@@ -29,21 +20,7 @@ test('basic webhook tests', async(t) => {
try { try {
await connect(srf); await connect(srf);
const verbs = [ await sippUac('uac-expect-603.xml', '172.38.0.10');
{
verb: 'sip:decline',
status: 603,
reason: 'Gone Fishin',
headers: {
'Retry-After': 300
}
}
];
const from = 'sip_decline_test_success';
provisionCallHook(from, verbs)
await sippUac('uac-expect-603.xml', '172.38.0.10', from);
t.pass('webhook successfully declines call'); t.pass('webhook successfully declines call');
disconnect(); disconnect();
@@ -53,43 +30,3 @@ test('basic webhook tests', async(t) => {
t.error(err); t.error(err);
} }
}); });
test('invalid jambonz json create alert tests', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
// Invalid json array
const verbs = {
verb: 'say',
text: 'hello'
};
const from = 'invalid_json_create_alert';
provisionCallHook(from, verbs)
// THEN
await sippUac('uac-invite-expect-480.xml', '172.38.0.10', from);
// sleep testcase for more than 7 second to wait alert pushed to database.
await sleep(8000);
const data = await queryAlerts(
{account_sid: 'bb845d4b-83a9-4cde-a6e9-50f3743bab3f', page: 1, page_size: 25, days: 7});
let checked = false;
for (let i = 0; i < data.total; i++) {
checked = data.data[i].message === 'malformed jambonz payload: must be array'
}
t.ok(checked, 'alert is raised as expected');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

View File

@@ -1,61 +0,0 @@
const opentelemetry = require('@opentelemetry/api');
const { registerInstrumentations } = require('@opentelemetry/instrumentation');
const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node');
const { Resource } = require('@opentelemetry/resources');
const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
const { BatchSpanProcessor } = require('@opentelemetry/sdk-trace-base');
const { JaegerExporter } = require('@opentelemetry/exporter-jaeger');
const { ZipkinExporter } = require('@opentelemetry/exporter-zipkin');
const { OTLPTraceExporter } = require ('@opentelemetry/exporter-trace-otlp-http');
//const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
//const { ExpressInstrumentation } = require('@opentelemetry/instrumentation-express');
//const { PinoInstrumentation } = require('@opentelemetry/instrumentation-pino');
module.exports = (serviceName) => {
if (process.env.JAMBONES_OTEL_ENABLED) {
const {version} = require('./package.json');
const provider = new NodeTracerProvider({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: serviceName,
[SemanticResourceAttributes.SERVICE_VERSION]: version,
}),
});
let exporter;
if (process.env.OTEL_EXPORTER_JAEGER_AGENT_HOST || process.env.OTEL_EXPORTER_JAEGER_ENDPOINT) {
exporter = new JaegerExporter();
}
else if (process.env.OTEL_EXPORTER_ZIPKIN_URL) {
exporter = new ZipkinExporter({url:process.env.OTEL_EXPORTER_ZIPKIN_URL});
}
else {
exporter = new OTLPTraceExporter({
url: process.OTEL_EXPORTER_COLLECTOR_URL
});
}
provider.addSpanProcessor(new BatchSpanProcessor(exporter, {
// The maximum queue size. After the size is reached spans are dropped.
maxQueueSize: 100,
// The maximum batch size of every export. It must be smaller or equal to maxQueueSize.
maxExportBatchSize: 10,
// The interval between two consecutive exports
scheduledDelayMillis: 500,
// How long the export can run before it is cancelled
exportTimeoutMillis: 30000,
}));
// Initialize the OpenTelemetry APIs to use the NodeTracerProvider bindings
provider.register();
registerInstrumentations({
instrumentations: [
//new HttpInstrumentation(),
//new ExpressInstrumentation(),
//new PinoInstrumentation()
],
});
}
return opentelemetry.trace.getTracer(serviceName);
};