mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2026-01-25 02:07:56 +00:00
Compare commits
203 Commits
feature/qu
...
feature/si
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34f2bd9d6c | ||
|
|
86627e3b2f | ||
|
|
72913560af | ||
|
|
16284b58a3 | ||
|
|
627c38899f | ||
|
|
bdb40b3aa0 | ||
|
|
12ad7e556f | ||
|
|
05d6c8d467 | ||
|
|
5e9407ff4e | ||
|
|
e4fefe8f44 | ||
|
|
f7aac33af4 | ||
|
|
dc1d8de396 | ||
|
|
5be5b6d05d | ||
|
|
f51211b407 | ||
|
|
7f0e373e5f | ||
|
|
c3e5ffa52d | ||
|
|
0ee13fb794 | ||
|
|
4e84098036 | ||
|
|
6d34850dc6 | ||
|
|
76ff1835a6 | ||
|
|
a4e358596e | ||
|
|
c412554c6b | ||
|
|
34fe22f6e1 | ||
|
|
182ad8c716 | ||
|
|
036accab44 | ||
|
|
b37881a059 | ||
|
|
258e4b5434 | ||
|
|
aa4d72c80a | ||
|
|
5c38ace5ba | ||
|
|
dea58c2605 | ||
|
|
eb0f55e0e3 | ||
|
|
944b8a29ca | ||
|
|
daa02ac55a | ||
|
|
5134d5dbc6 | ||
|
|
a755e25568 | ||
|
|
13549286db | ||
|
|
72aaf80335 | ||
|
|
af33089a8a | ||
|
|
85d86cfdc3 | ||
|
|
de9f2ce5ca | ||
|
|
36c97e9562 | ||
|
|
13ea559cb1 | ||
|
|
698d12a95f | ||
|
|
359cb82d80 | ||
|
|
29dec24095 | ||
|
|
6330b0d443 | ||
|
|
24a0bc547f | ||
|
|
db5486de27 | ||
|
|
41d6c74c8e | ||
|
|
92ca40c9b3 | ||
|
|
3fa913215f | ||
|
|
0b132411c1 | ||
|
|
077d34dc9e | ||
|
|
49a75a3e3a | ||
|
|
6f214a66e8 | ||
|
|
3456c51118 | ||
|
|
13c38a9875 | ||
|
|
4f87cf9b38 | ||
|
|
bf21a1f9a4 | ||
|
|
81f6163aca | ||
|
|
547ca0281f | ||
|
|
3281a213c8 | ||
|
|
4f2fc70383 | ||
|
|
f72e8e654c | ||
|
|
cf2100f925 | ||
|
|
5a584f50da | ||
|
|
befe910503 | ||
|
|
040ec0db9b | ||
|
|
8459376f88 | ||
|
|
775a317821 | ||
|
|
9004f654ff | ||
|
|
6163657845 | ||
|
|
398daa87d5 | ||
|
|
4f5ab7d146 | ||
|
|
70f7775893 | ||
|
|
a950f9f738 | ||
|
|
ff8d7f3648 | ||
|
|
6e4ae69cb7 | ||
|
|
23eae34888 | ||
|
|
aaf94006db | ||
|
|
86b030db93 | ||
|
|
6abfdafe05 | ||
|
|
f1f83598ca | ||
|
|
3dd703411c | ||
|
|
8c5cdd374b | ||
|
|
15d784a4b0 | ||
|
|
7188648d3b | ||
|
|
d00ea5c95f | ||
|
|
ddcbda988f | ||
|
|
ddf00c0ddf | ||
|
|
fd8df533ab | ||
|
|
4b1199242f | ||
|
|
72225791b9 | ||
|
|
172dc1aaa7 | ||
|
|
72b74de767 | ||
|
|
9908485eb8 | ||
|
|
fb25389cd1 | ||
|
|
f317fbaa45 | ||
|
|
3c5d392407 | ||
|
|
5bfc451c85 | ||
|
|
47478fd409 | ||
|
|
c16a2662f2 | ||
|
|
c1130adf03 | ||
|
|
f982f6c7d8 | ||
|
|
f20190b0fc | ||
|
|
74e85e1b16 | ||
|
|
63e9cb985e | ||
|
|
2e88ab1f55 | ||
|
|
7f75a35515 | ||
|
|
941727e93f | ||
|
|
d8bfa33a00 | ||
|
|
30ed5b6a02 | ||
|
|
bac1b7f2c6 | ||
|
|
48deb3ae89 | ||
|
|
de83f735ea | ||
|
|
cfe9397502 | ||
|
|
dda3335060 | ||
|
|
2329f0cda0 | ||
|
|
36683dc151 | ||
|
|
ce738a7852 | ||
|
|
77a696a0dc | ||
|
|
62ff44540d | ||
|
|
e5821cddf8 | ||
|
|
25567a7842 | ||
|
|
40bd3c9c88 | ||
|
|
27d6d32359 | ||
|
|
142f5d409f | ||
|
|
da4a7184a4 | ||
|
|
2c72bf50cd | ||
|
|
b27f349fc6 | ||
|
|
138aa5836a | ||
|
|
e1a023c21e | ||
|
|
8acb4d1a24 | ||
|
|
26d4bfb63b | ||
|
|
45dcab8517 | ||
|
|
27e3cba00b | ||
|
|
097f36cb00 | ||
|
|
752eed428f | ||
|
|
afb874aabc | ||
|
|
59227febf9 | ||
|
|
8593f12b51 | ||
|
|
3bf1984854 | ||
|
|
0e45e9b27c | ||
|
|
b0a8a6828d | ||
|
|
27d4ad5674 | ||
|
|
d38e77c06c | ||
|
|
c9e2a162c2 | ||
|
|
2b9cb5105f | ||
|
|
afbbed3f5c | ||
|
|
f642967f02 | ||
|
|
fbe2aa2c06 | ||
|
|
5321b5c651 | ||
|
|
83c114803f | ||
|
|
0663174f46 | ||
|
|
3d4359fbe4 | ||
|
|
10382573fa | ||
|
|
c190279927 | ||
|
|
114f65b36a | ||
|
|
3e49616191 | ||
|
|
1e93973419 | ||
|
|
fe1778e9ae | ||
|
|
af15449451 | ||
|
|
12c34de15c | ||
|
|
7c77bedd15 | ||
|
|
0c5150cb30 | ||
|
|
2262973f43 | ||
|
|
db78ffffed | ||
|
|
2930cd6aaf | ||
|
|
2a013377cc | ||
|
|
dcf27ba5d3 | ||
|
|
f11feb7975 | ||
|
|
19dda9398d | ||
|
|
81edf1a6d6 | ||
|
|
72345f83c1 | ||
|
|
bedf25c6a2 | ||
|
|
a9e789f466 | ||
|
|
a779ead79f | ||
|
|
a3d3878218 | ||
|
|
4bc3e03605 | ||
|
|
62106a751f | ||
|
|
4c61ae5fbd | ||
|
|
708c13d5f6 | ||
|
|
7cf342eeb8 | ||
|
|
aebcf2b006 | ||
|
|
f0bd681ccc | ||
|
|
ac263de729 | ||
|
|
862405c232 | ||
|
|
3cd4c399d4 | ||
|
|
0d6cb8a2b3 | ||
|
|
05c5319cbc | ||
|
|
d15fdcf663 | ||
|
|
19f3cbaa43 | ||
|
|
ac8827c885 | ||
|
|
d1d082ceaf | ||
|
|
28415dc750 | ||
|
|
3d0c7fea52 | ||
|
|
3fed15b3b9 | ||
|
|
7c629e6faf | ||
|
|
649b3d5715 | ||
|
|
48fbbd48ad | ||
|
|
dacd3691ed | ||
|
|
df8dac367c | ||
|
|
1a2aaf9845 |
@@ -8,7 +8,7 @@
|
||||
"jsx": false,
|
||||
"modules": false
|
||||
},
|
||||
"ecmaVersion": 2018
|
||||
"ecmaVersion": 2020
|
||||
},
|
||||
"plugins": ["promise"],
|
||||
"rules": {
|
||||
|
||||
51
.github/workflows/docker-publish.yml
vendored
Normal file
51
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
# Publish `main` as Docker `latest` image.
|
||||
branches:
|
||||
- main
|
||||
|
||||
# Publish `v1.2.3` tags as releases.
|
||||
tags:
|
||||
- v*
|
||||
|
||||
env:
|
||||
IMAGE_NAME: feature-server
|
||||
|
||||
jobs:
|
||||
push:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Build image
|
||||
run: docker build . --file Dockerfile --tag $IMAGE_NAME
|
||||
|
||||
- name: Log into registry
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Push image
|
||||
run: |
|
||||
IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME
|
||||
|
||||
# Change all uppercase to lowercase
|
||||
IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
|
||||
|
||||
# Strip git ref prefix from version
|
||||
VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
|
||||
|
||||
# Strip "v" prefix from tag name
|
||||
[[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')
|
||||
|
||||
# Use Docker `latest` tag convention
|
||||
[ "$VERSION" == "main" ] && VERSION=latest
|
||||
|
||||
echo IMAGE_ID=$IMAGE_ID
|
||||
echo VERSION=$VERSION
|
||||
|
||||
docker tag $IMAGE_NAME $IMAGE_ID:$VERSION
|
||||
docker push $IMAGE_ID:$VERSION
|
||||
12
Dockerfile
12
Dockerfile
@@ -1,15 +1,9 @@
|
||||
FROM node:alpine as builder
|
||||
RUN apk update && apk add --no-cache python make g++
|
||||
FROM node:lts-slim
|
||||
WORKDIR /opt/app/
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
RUN npm prune
|
||||
|
||||
FROM node:alpine as app
|
||||
WORKDIR /opt/app
|
||||
COPY . /opt/app
|
||||
COPY --from=builder /opt/app/node_modules ./node_modules
|
||||
|
||||
ARG NODE_ENV
|
||||
ENV NODE_ENV $NODE_ENV
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
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 is provided via environment variables:
|
||||
@@ -84,7 +86,5 @@ module.exports = {
|
||||
```
|
||||
|
||||
#### 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.
|
||||
```
|
||||
npm test
|
||||
```
|
||||
|
||||
Please [see this]](./docs/contributing.md#run-the-regression-test-suite).
|
||||
42
app.js
42
app.js
@@ -7,22 +7,27 @@ assert.ok(process.env.DRACHTIO_PORT || process.env.DRACHTIO_HOST, 'missing DRACH
|
||||
assert.ok(process.env.DRACHTIO_SECRET, 'missing DRACHTIO_SECRET env var');
|
||||
assert.ok(process.env.JAMBONES_FREESWITCH, 'missing JAMBONES_FREESWITCH env var');
|
||||
assert.ok(process.env.JAMBONES_REDIS_HOST, 'missing JAMBONES_REDIS_HOST env var');
|
||||
assert.ok(process.env.JAMBONES_NETWORK_CIDR, 'missing JAMBONES_SUBNET env var');
|
||||
assert.ok(process.env.JAMBONES_NETWORK_CIDR || process.env.K8S, 'missing JAMBONES_SUBNET env var');
|
||||
|
||||
const Srf = require('drachtio-srf');
|
||||
const srf = new Srf();
|
||||
const tracer = require('./tracer')(process.env.JAMBONES_OTEL_SERVICE_NAME || 'jambonz-feature-server');
|
||||
const api = require('@opentelemetry/api');
|
||||
srf.locals = {...srf.locals, otel: {tracer, api}};
|
||||
|
||||
const PORT = process.env.HTTP_PORT || 3000;
|
||||
const opts = {
|
||||
timestamp: () => {return `, "time": "${new Date().toISOString()}"`;},
|
||||
level: process.env.JAMBONES_LOGLEVEL || 'info'
|
||||
};
|
||||
const logger = require('pino')(opts);
|
||||
const {LifeCycleEvents} = require('./lib/utils/constants');
|
||||
const {LifeCycleEvents, FS_UUID_SET_NAME} = require('./lib/utils/constants');
|
||||
const installSrfLocals = require('./lib/utils/install-srf-locals');
|
||||
installSrfLocals(srf, logger);
|
||||
|
||||
const {
|
||||
initLocals,
|
||||
createRootSpan,
|
||||
getAccountDetails,
|
||||
normalizeNumbers,
|
||||
retrieveApplication,
|
||||
@@ -31,6 +36,7 @@ const {
|
||||
|
||||
// HTTP
|
||||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const app = express();
|
||||
Object.assign(app.locals, {
|
||||
logger,
|
||||
@@ -61,6 +67,7 @@ if (process.env.NODE_ENV === 'test') {
|
||||
|
||||
srf.use('invite', [
|
||||
initLocals,
|
||||
createRootSpan,
|
||||
getAccountDetails,
|
||||
normalizeNumbers,
|
||||
retrieveApplication,
|
||||
@@ -73,6 +80,8 @@ srf.invite((req, res) => {
|
||||
});
|
||||
|
||||
// HTTP
|
||||
app.use(helmet());
|
||||
app.use(helmet.hidePoweredBy());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.use('/', httpRoutes);
|
||||
@@ -92,6 +101,10 @@ sessionTracker.on('idle', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const getCount = () => sessionTracker.count;
|
||||
const healthCheck = require('@jambonz/http-health-check');
|
||||
healthCheck({app, logger, path: '/', fn: getCount});
|
||||
|
||||
setInterval(() => {
|
||||
srf.locals.stats.gauge('fs.sip.calls.count', sessionTracker.count);
|
||||
}, 5000);
|
||||
@@ -105,4 +118,29 @@ const disconnect = () => {
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGUSR2', handle);
|
||||
process.on('SIGTERM', handle);
|
||||
|
||||
function handle(signal) {
|
||||
const {removeFromSet} = srf.locals.dbHelpers;
|
||||
const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-fs`;
|
||||
logger.info(`got signal ${signal}, removing ${srf.locals.localSipAddress} from set ${setName}`);
|
||||
removeFromSet(setName, srf.locals.localSipAddress);
|
||||
removeFromSet(FS_UUID_SET_NAME, srf.locals.fsUUID);
|
||||
srf.locals.disabled = true;
|
||||
}
|
||||
|
||||
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};
|
||||
|
||||
29
bin/k8s-pre-stop-hook.js
Executable file
29
bin/k8s-pre-stop-hook.js
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
const bent = require('bent');
|
||||
const getJSON = bent('json');
|
||||
const PORT = process.env.HTTP_PORT || 3000;
|
||||
|
||||
const sleep = (ms) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
|
||||
(async function() {
|
||||
|
||||
try {
|
||||
do {
|
||||
const obj = await getJSON(`http://127.0.0.1:${PORT}/`);
|
||||
const {calls} = obj;
|
||||
if (calls === 0) {
|
||||
console.log('no calls on the system, we can exit');
|
||||
process.exit(0);
|
||||
}
|
||||
else {
|
||||
console.log(`waiting for ${calls} to exit..`);
|
||||
}
|
||||
await sleep(10000);
|
||||
} while (1);
|
||||
} catch (err) {
|
||||
console.error(err, 'Error querying health endpoint');
|
||||
process.exit(-1);
|
||||
}
|
||||
})();
|
||||
123
docs/contributing.md
Normal file
123
docs/contributing.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# 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.
|
||||
|
||||
|
||||
|
||||
@@ -6,17 +6,20 @@ const {CallDirection, CallStatus} = require('../../utils/constants');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const SipError = require('drachtio-srf').SipError;
|
||||
const sysError = require('./error');
|
||||
const Requestor = require('../../utils/requestor');
|
||||
const HttpRequestor = require('../../utils/http-requestor');
|
||||
const WsRequestor = require('../../utils/ws-requestor');
|
||||
const RootSpan = require('../../utils/call-tracer');
|
||||
const dbUtils = require('../../utils/db-utils');
|
||||
|
||||
router.post('/', async(req, res) => {
|
||||
const {logger} = req.app.locals;
|
||||
const accountSid = req.body.account_sid;
|
||||
const {srf} = require('../../..');
|
||||
|
||||
logger.debug({body: req.body}, 'got createCall request');
|
||||
try {
|
||||
let uri, cs, to;
|
||||
const restDial = makeTask(logger, {'rest:dial': req.body});
|
||||
const {srf} = require('../../..');
|
||||
const {lookupAccountDetails} = dbUtils(logger, srf);
|
||||
const {getSBC, getFreeswitch} = srf.locals;
|
||||
const sbcAddress = getSBC();
|
||||
@@ -35,8 +38,10 @@ router.post('/', async(req, res) => {
|
||||
|
||||
opts.headers = {
|
||||
...opts.headers,
|
||||
'X-Jambonz-Routing': target.type,
|
||||
'X-Jambonz-FS-UUID': srf.locals.fsUUID,
|
||||
'X-Call-Sid': callSid,
|
||||
'X-Account-Sid': req.body.account_sid
|
||||
'X-Account-Sid': accountSid
|
||||
};
|
||||
|
||||
switch (target.type) {
|
||||
@@ -45,7 +50,7 @@ router.post('/', async(req, res) => {
|
||||
uri = `sip:${target.number}@${sbcAddress}`;
|
||||
to = target.number;
|
||||
if ('teams' === target.type) {
|
||||
const obj = await lookupTeamsByAccount(req.body.account_sid);
|
||||
const obj = await lookupTeamsByAccount(accountSid);
|
||||
if (!obj) throw new Error('dial to ms teams not allowed; account must first be configured with teams info');
|
||||
Object.assign(opts.headers, {
|
||||
'X-MS-Teams-FQDN': obj.ms_teams_fqdn,
|
||||
@@ -57,6 +62,11 @@ router.post('/', async(req, res) => {
|
||||
case 'user':
|
||||
uri = `sip:${target.name}`;
|
||||
to = target.name;
|
||||
if (target.overrideTo) {
|
||||
Object.assign(opts.headers, {
|
||||
'X-Override-To': target.overrideTo
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'sip':
|
||||
uri = target.sipUri;
|
||||
@@ -64,6 +74,17 @@ router.post('/', async(req, res) => {
|
||||
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 */
|
||||
const ms = getFreeswitch();
|
||||
if (!ms) throw new Error('no available Freeswitch for outbound call creation');
|
||||
@@ -97,24 +118,62 @@ router.post('/', async(req, res) => {
|
||||
* attach our requestor and notifier objects
|
||||
* these will be used for all http requests we make during this call
|
||||
*/
|
||||
app.requestor = new Requestor(logger, account.account_sid, app.call_hook, account.webhook_secret);
|
||||
if (app.call_status_hook) {
|
||||
app.notifier = new Requestor(logger, account.account_sid, app.call_status_hook, account.webhook_secret);
|
||||
if ('WS' === app.call_hook?.method || /^wss?:/.test(app.call_hook.url)) {
|
||||
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) ;
|
||||
if (app.call_hook.url === app.call_status_hook.url || !app.call_status_hook?.url) {
|
||||
logger.debug('reusing websocket for call status hook');
|
||||
app.notifier = app.requestor;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
if (!app.notifier && app.call_status_hook) {
|
||||
app.notifier = new HttpRequestor(logger, account.account_sid, app.call_status_hook, account.webhook_secret);
|
||||
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: () => {}};
|
||||
}
|
||||
else app.notifier = {request: () => {}};
|
||||
|
||||
/* now launch the outdial */
|
||||
try {
|
||||
const dlg = await srf.createUAC(uri, opts, {
|
||||
const dlg = await srf.createUAC(uri, {...opts, followRedirects: true, keepUriOnRedirect: true}, {
|
||||
cbRequest: (err, inviteReq) => {
|
||||
/* in case of 302 redirect, this gets called twice, ignore the second
|
||||
except to update the req so that it can later be canceled if need be
|
||||
*/
|
||||
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) {
|
||||
logger.error(err, 'createCall Error creating call');
|
||||
res.status(500).send('Call Failure');
|
||||
return;
|
||||
}
|
||||
inviteReq.srf = srf;
|
||||
inviteReq.locals = {
|
||||
...(inviteReq || {}),
|
||||
callSid,
|
||||
application_sid: app.application_sid
|
||||
};
|
||||
/* ok our outbound INVITE is in flight */
|
||||
|
||||
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({
|
||||
direction: CallDirection.Outbound,
|
||||
req: inviteReq,
|
||||
@@ -122,17 +181,24 @@ router.post('/', async(req, res) => {
|
||||
tag: app.tag,
|
||||
callSid,
|
||||
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);
|
||||
|
||||
res.status(201).json({sid: cs.callSid});
|
||||
|
||||
sipLogger = logger.child({
|
||||
callSid: cs.callSid,
|
||||
callId: callInfo.callId
|
||||
});
|
||||
sipLogger.info(`outbound REST call attempt to ${JSON.stringify(target)} has been sent`);
|
||||
},
|
||||
cbProvisional: (prov) => {
|
||||
@@ -143,7 +209,11 @@ router.post('/', async(req, res) => {
|
||||
}
|
||||
});
|
||||
connectStream(dlg.remote.sdp);
|
||||
cs.emit('callStatusChange', {callStatus: CallStatus.InProgress, sipStatus: 200});
|
||||
cs.emit('callStatusChange', {
|
||||
callStatus: CallStatus.InProgress,
|
||||
sipStatus: 200,
|
||||
sipReason: 'OK'
|
||||
});
|
||||
restDial.emit('callStatus', 200);
|
||||
restDial.emit('connect', dlg);
|
||||
}
|
||||
@@ -154,14 +224,23 @@ router.post('/', async(req, res) => {
|
||||
else if (487 === err.status) callStatus = CallStatus.NoAnswer;
|
||||
if (sipLogger) sipLogger.info(`REST outdial failed with ${err.status}`);
|
||||
else console.log(`REST outdial failed with ${err.status}`);
|
||||
if (cs) cs.emit('callStatusChange', {callStatus, sipStatus: err.status});
|
||||
if (cs) cs.emit('callStatusChange', {
|
||||
callStatus,
|
||||
sipStatus: err.status,
|
||||
sipReason: err.reason
|
||||
});
|
||||
}
|
||||
else {
|
||||
if (cs) cs.emit('callStatusChange', {callStatus, sipStatus: 500});
|
||||
if (cs) cs.emit('callStatusChange', {
|
||||
callStatus,
|
||||
sipStatus: 500,
|
||||
sipReason: 'Internal Server Error'
|
||||
});
|
||||
if (sipLogger) sipLogger.error({err}, 'REST outdial failed');
|
||||
else console.error(err);
|
||||
}
|
||||
ep.destroy();
|
||||
setTimeout(restDial.kill.bind(restDial), 5000);
|
||||
}
|
||||
} catch (err) {
|
||||
sysError(logger, res, err);
|
||||
|
||||
@@ -9,8 +9,4 @@ api.use('/enqueue', require('./enqueue'));
|
||||
api.use('/messaging', require('./messaging')); // inbound SMS
|
||||
api.use('/createMessage', require('./create-message')); // outbound SMS (REST)
|
||||
|
||||
// health checks
|
||||
api.get('/', (req, res) => res.sendStatus(200));
|
||||
api.get('/health', (req, res) => res.sendStatus(200));
|
||||
|
||||
module.exports = api;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const router = require('express').Router();
|
||||
const Requestor = require('../../utils/requestor');
|
||||
const HttpRequestor = require('../../utils/http-requestor');
|
||||
const WsRequestor = require('../../utils/ws-requestor');
|
||||
const CallInfo = require('../../session/call-info');
|
||||
const {CallDirection} = require('../../utils/constants');
|
||||
const SmsSession = require('../../session/sms-call-session');
|
||||
@@ -18,7 +19,17 @@ router.post('/:partner', async(req, res) => {
|
||||
const app = req.body.app;
|
||||
const account = await lookupAccountBySid(app.accountSid);
|
||||
const hook = app.messaging_hook;
|
||||
const requestor = new Requestor(logger, account.account_sid, hook, account.webhook_secret);
|
||||
let requestor;
|
||||
|
||||
if ('WS' === hook?.method) {
|
||||
app.requestor = new WsRequestor(logger, account.account_sid, hook, account.webhook_secret) ;
|
||||
app.notifier = app.requestor;
|
||||
}
|
||||
else {
|
||||
app.requestor = new HttpRequestor(logger, account.account_sid, hook, account.webhook_secret);
|
||||
app.notifier = {request: () => {}};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
carrier: req.params.partner,
|
||||
messageSid: app.messageSid,
|
||||
@@ -33,7 +44,7 @@ router.post('/:partner', async(req, res) => {
|
||||
res.status(200).json({sid: req.body.messageSid});
|
||||
|
||||
try {
|
||||
tasks = await requestor.request(hook, payload);
|
||||
tasks = await requestor.request('session:new', hook, payload);
|
||||
logger.info({tasks}, 'response from incoming SMS webhook');
|
||||
} catch (err) {
|
||||
logger.error({err, hook}, 'Error sending incoming SMS message');
|
||||
|
||||
@@ -12,6 +12,9 @@ function retrieveCallSession(callSid, opts) {
|
||||
throw new DbErrorBadRequest('call_status_hook can be updated only when call_hook is also being updated');
|
||||
}
|
||||
const cs = sessionTracker.get(callSid);
|
||||
if (!cs) {
|
||||
throw new DbErrorUnprocessableRequest('call session is gone');
|
||||
}
|
||||
|
||||
if (opts.call_status === CallStatus.Completed && !cs.hasStableDialog) {
|
||||
throw new DbErrorUnprocessableRequest('current call state is incompatible with requested action');
|
||||
@@ -45,8 +48,18 @@ router.post('/:callSid', async(req, res) => {
|
||||
logger.info(`updateCall: callSid not found ${callSid}`);
|
||||
return res.sendStatus(404);
|
||||
}
|
||||
res.sendStatus(202);
|
||||
cs.updateCall(req.body, callSid);
|
||||
|
||||
if (req.body.sip_request) {
|
||||
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) {
|
||||
sysError(logger, res, err);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
const express = require('express');
|
||||
const api = require('./api');
|
||||
const routes = express.Router();
|
||||
const sessionTracker = require('../session/session-tracker');
|
||||
|
||||
const readiness = (req, res) => {
|
||||
const logger = req.app.locals.logger;
|
||||
const {count} = sessionTracker;
|
||||
const {srf} = require('../..');
|
||||
const {getFreeswitch} = srf.locals;
|
||||
if (getFreeswitch()) {
|
||||
return res.status(200).json({calls: count});
|
||||
}
|
||||
logger.info('responding to /health check with failure as freeswitch is not up');
|
||||
res.sendStatus(480);
|
||||
};
|
||||
|
||||
routes.use('/v1', api);
|
||||
|
||||
// health checks
|
||||
routes.get('/', (req, res) => {
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
routes.get('/health', (req, res) => {
|
||||
res.sendStatus(200);
|
||||
});
|
||||
// health check
|
||||
routes.get('/health', readiness);
|
||||
|
||||
module.exports = routes;
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const {CallDirection} = require('./utils/constants');
|
||||
const CallInfo = require('./session/call-info');
|
||||
const Requestor = require('./utils/requestor');
|
||||
const HttpRequestor = require('./utils/http-requestor');
|
||||
const WsRequestor = require('./utils/ws-requestor');
|
||||
const makeTask = require('./tasks/make_task');
|
||||
const parseUri = require('drachtio-srf').parseUri;
|
||||
const normalizeJambones = require('./utils/normalize-jambones');
|
||||
const dbUtils = require('./utils/db-utils');
|
||||
const RootSpan = require('./utils/call-tracer');
|
||||
const listTaskNames = require('./utils/summarize-tasks');
|
||||
|
||||
module.exports = function(srf, logger) {
|
||||
const {lookupAppByPhoneNumber, lookupAppBySid, lookupAppByRealm, lookupAppByTeamsTenant} = srf.locals.dbHelpers;
|
||||
const {
|
||||
lookupAppByPhoneNumber,
|
||||
lookupAppByRegex,
|
||||
lookupAppBySid,
|
||||
lookupAppByRealm,
|
||||
lookupAppByTeamsTenant
|
||||
} = srf.locals.dbHelpers;
|
||||
const {lookupAccountDetails} = dbUtils(logger, srf);
|
||||
|
||||
function initLocals(req, res, next) {
|
||||
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();
|
||||
req.locals = {
|
||||
callSid,
|
||||
logger: logger.child({callId: req.get('Call-ID'), callSid})
|
||||
};
|
||||
const account_sid = req.get('X-Account-Sid');
|
||||
req.locals = {callSid, account_sid};
|
||||
if (req.has('X-Application-Sid')) {
|
||||
const application_sid = req.get('X-Application-Sid');
|
||||
req.locals.logger.debug(`got application from X-Application-Sid header: ${application_sid}`);
|
||||
logger.debug(`got application from X-Application-Sid header: ${application_sid}`);
|
||||
req.locals.application_sid = application_sid;
|
||||
}
|
||||
if (req.has('X-Authenticated-User')) req.locals.originatingUser = req.get('X-Authenticated-User');
|
||||
@@ -27,27 +39,59 @@ module.exports = function(srf, logger) {
|
||||
next();
|
||||
}
|
||||
|
||||
function createRootSpan(req, res, next) {
|
||||
const {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: req.get('Call-ID'),
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* retrieve account information for the incoming call
|
||||
*/
|
||||
async function getAccountDetails(req, res, next) {
|
||||
const {rootSpan, account_sid} = req.locals;
|
||||
|
||||
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');
|
||||
|
||||
const {span} = rootSpan.startChildSpan('lookupAccountDetails');
|
||||
try {
|
||||
req.locals.accountInfo = await lookupAccountDetails(account_sid);
|
||||
span.end();
|
||||
if (!req.locals.accountInfo.account.is_active) {
|
||||
logger.info(`Account is inactive or suspended ${account_sid}`);
|
||||
// TODO: alert
|
||||
return res.send(503, {headers: {'X-Reason': 'Account exists but is inactive'}});
|
||||
}
|
||||
logger.debug({accountInfo: req.locals.accountInfo}, `retrieved account info for ${account_sid}`);
|
||||
logger.debug({accountInfo: req.locals?.accountInfo?.account}, `retrieved account info for ${account_sid}`);
|
||||
next();
|
||||
} catch (err) {
|
||||
span.end();
|
||||
logger.info({err}, `Error retrieving account details for account ${account_sid}`);
|
||||
res.send(503, {headers: {'X-Reason': `No Account exists for sid ${account_sid}`}});
|
||||
}
|
||||
@@ -79,7 +123,8 @@ module.exports = function(srf, logger) {
|
||||
*/
|
||||
async function retrieveApplication(req, res, next) {
|
||||
const logger = req.locals.logger;
|
||||
const {accountInfo, account_sid} = req.locals;
|
||||
const {accountInfo, account_sid, rootSpan} = req.locals;
|
||||
const {span} = rootSpan.startChildSpan('lookupApplication');
|
||||
try {
|
||||
let app;
|
||||
if (req.locals.application_sid) app = await lookupAppBySid(req.locals.application_sid);
|
||||
@@ -99,7 +144,7 @@ module.exports = function(srf, logger) {
|
||||
}
|
||||
else {
|
||||
const uri = parseUri(req.uri);
|
||||
const arr = /context-(.*)/.exec(uri.user);
|
||||
const arr = /context-(.*)/.exec(uri?.user);
|
||||
if (arr) {
|
||||
// this is a transfer from another feature server
|
||||
const {retrieveKey, deleteKey} = srf.locals.dbHelpers;
|
||||
@@ -112,9 +157,22 @@ module.exports = function(srf, logger) {
|
||||
logger.error(err, `Error retrieving transferred call app for ${arr[1]}`);
|
||||
}
|
||||
}
|
||||
else app = await lookupAppByPhoneNumber(req.locals.calledNumber);
|
||||
else {
|
||||
const voip_carrier_sid = req.get('X-Voip-Carrier-Sid');
|
||||
app = await lookupAppByPhoneNumber(req.locals.calledNumber, voip_carrier_sid);
|
||||
|
||||
if (!app) {
|
||||
/* lookup by call_routes.regex */
|
||||
app = await lookupAppByRegex(req.locals.calledNumber, account_sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
logger.info(`rejecting call to ${req.locals.calledNumber}: no application or webhook url`);
|
||||
return res.send(480, {
|
||||
@@ -128,19 +186,35 @@ module.exports = function(srf, logger) {
|
||||
* 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).
|
||||
*/
|
||||
app.requestor = new Requestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret);
|
||||
if (app.call_status_hook) app.notifier = new Requestor(logger, account_sid, app.call_status_hook,
|
||||
accountInfo.account.webhook_secret);
|
||||
else app.notifier = {request: () => {}};
|
||||
if ('WS' === app.call_hook?.method ||
|
||||
app.call_hook?.url.startsWith('ws://') || app.call_hook?.url.startsWith('wss://')) {
|
||||
app.requestor = new WsRequestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret) ;
|
||||
app.notifier = app.requestor;
|
||||
app.call_hook.method = 'WS';
|
||||
}
|
||||
else {
|
||||
app.requestor = new HttpRequestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret);
|
||||
if (app.call_status_hook) app.notifier = new HttpRequestor(logger, account_sid, app.call_status_hook,
|
||||
accountInfo.account.webhook_secret);
|
||||
else app.notifier = {request: () => {}};
|
||||
}
|
||||
|
||||
req.locals.application = app;
|
||||
const obj = Object.assign({}, app);
|
||||
delete obj.requestor;
|
||||
delete obj.notifier;
|
||||
logger.info({app: obj}, `retrieved application for incoming call to ${req.locals.calledNumber}`);
|
||||
req.locals.callInfo = new CallInfo({req, app, direction: CallDirection.Inbound});
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const {call_hook, call_status_hook, ...appInfo} = obj; // mask sensitive data like user/pass on webhook
|
||||
logger.info({app: appInfo}, `retrieved application for incoming call to ${req.locals.calledNumber}`);
|
||||
req.locals.callInfo = new CallInfo({
|
||||
req,
|
||||
app,
|
||||
direction: CallDirection.Inbound,
|
||||
traceId: rootSpan.traceId
|
||||
});
|
||||
next();
|
||||
} catch (err) {
|
||||
span.end();
|
||||
logger.error(err, `${req.get('Call-ID')} Error looking up application for ${req.calledNumber}`);
|
||||
res.send(500);
|
||||
}
|
||||
@@ -151,29 +225,55 @@ module.exports = function(srf, logger) {
|
||||
*/
|
||||
async function invokeWebCallback(req, res, next) {
|
||||
const logger = req.locals.logger;
|
||||
const app = req.locals.application;
|
||||
const {rootSpan, application:app} = req.locals;
|
||||
let span;
|
||||
try {
|
||||
|
||||
if (app.tasks) {
|
||||
app.tasks = normalizeJambones(logger, app.tasks).map((tdata) => makeTask(logger, tdata));
|
||||
if (0 === app.tasks.length) throw new Error('no application provided');
|
||||
return next();
|
||||
}
|
||||
/* retrieve the application to execute for this inbound call */
|
||||
const params = Object.assign(app.call_hook.method === 'POST' ? {sip: req.msg} : {},
|
||||
req.locals.callInfo);
|
||||
const json = await app.requestor.request(app.call_hook, params);
|
||||
const params = Object.assign(['POST', 'WS'].includes(app.call_hook.method) ? {sip: req.msg} : {},
|
||||
req.locals.callInfo, {
|
||||
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));
|
||||
span.setAttributes({
|
||||
'http.statusCode': 200,
|
||||
'app.tasks': listTaskNames(app.tasks)
|
||||
});
|
||||
span.end();
|
||||
if (0 === app.tasks.length) throw new Error('no application provided');
|
||||
next();
|
||||
} catch (err) {
|
||||
logger.info(`Error retrieving or parsing application: ${err.message}`);
|
||||
res.send(480, {headers: {'X-Reason': err.message}});
|
||||
span?.setAttributes({webhookStatus: err.statusCode});
|
||||
span?.end();
|
||||
logger.info({err}, `Error retrieving or parsing application: ${err?.message}`);
|
||||
res.send(480, {headers: {'X-Reason': err?.message || 'unknown'}});
|
||||
app.requestor.close();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
initLocals,
|
||||
createRootSpan,
|
||||
getAccountDetails,
|
||||
normalizeNumbers,
|
||||
retrieveApplication,
|
||||
|
||||
@@ -8,13 +8,15 @@ const CallSession = require('./call-session');
|
||||
|
||||
*/
|
||||
class AdultingCallSession extends CallSession {
|
||||
constructor({logger, application, singleDialer, tasks, callInfo}) {
|
||||
constructor({logger, application, singleDialer, tasks, callInfo, accountInfo, rootSpan}) {
|
||||
super({
|
||||
logger,
|
||||
application,
|
||||
srf: singleDialer.dlg.srf,
|
||||
tasks,
|
||||
callInfo
|
||||
callInfo,
|
||||
accountInfo,
|
||||
rootSpan
|
||||
});
|
||||
this.sd = singleDialer;
|
||||
|
||||
@@ -29,15 +31,25 @@ class AdultingCallSession extends CallSession {
|
||||
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() {
|
||||
return this.sd.ep;
|
||||
}
|
||||
|
||||
/* see note above */
|
||||
set ep(newEp) {}
|
||||
|
||||
get callSid() {
|
||||
return this.callInfo.callSid;
|
||||
}
|
||||
|
||||
|
||||
_callerHungup() {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AdultingCallSession;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const {CallDirection, CallStatus} = require('../utils/constants');
|
||||
const parseUri = require('drachtio-srf').parseUri;
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
/**
|
||||
* @classdesc Represents the common information for all calls
|
||||
* that is provided in call status webhooks
|
||||
@@ -9,7 +8,9 @@ const { v4: uuidv4 } = require('uuid');
|
||||
class CallInfo {
|
||||
constructor(opts) {
|
||||
let from ;
|
||||
let srf;
|
||||
this.direction = opts.direction;
|
||||
this.traceId = opts.traceId;
|
||||
if (opts.req) {
|
||||
const u = opts.req.getParsedHeader('from');
|
||||
const uri = parseUri(u.uri);
|
||||
@@ -19,6 +20,7 @@ class CallInfo {
|
||||
if (this.direction === CallDirection.Inbound) {
|
||||
// inbound call
|
||||
const {app, req} = opts;
|
||||
srf = req.srf;
|
||||
this.callSid = req.locals.callSid,
|
||||
this.accountSid = app.account_sid,
|
||||
this.applicationSid = app.application_sid;
|
||||
@@ -26,6 +28,7 @@ class CallInfo {
|
||||
this.to = req.calledNumber;
|
||||
this.callId = req.get('Call-ID');
|
||||
this.sipStatus = 100;
|
||||
this.sipReason = 'Trying';
|
||||
this.callStatus = CallStatus.Trying;
|
||||
this.originatingSipIp = req.get('X-Forwarded-For');
|
||||
this.originatingSipTrunkName = req.get('X-Originating-Carrier');
|
||||
@@ -33,6 +36,7 @@ class CallInfo {
|
||||
else if (opts.parentCallInfo) {
|
||||
// outbound call that is a child of an existing call
|
||||
const {req, parentCallInfo, to, callSid} = opts;
|
||||
srf = req.srf;
|
||||
this.callSid = callSid || uuidv4();
|
||||
this.parentCallSid = parentCallInfo.callSid;
|
||||
this.accountSid = parentCallInfo.accountSid;
|
||||
@@ -43,10 +47,12 @@ class CallInfo {
|
||||
this.callId = req.get('Call-ID');
|
||||
this.callStatus = CallStatus.Trying,
|
||||
this.sipStatus = 100;
|
||||
this.sipReason = 'Trying';
|
||||
}
|
||||
else if (this.direction === CallDirection.None) {
|
||||
// outbound SMS
|
||||
const {messageSid, accountSid, applicationSid, res} = opts;
|
||||
srf = res.srf;
|
||||
this.messageSid = messageSid;
|
||||
this.accountSid = accountSid;
|
||||
this.applicationSid = applicationSid;
|
||||
@@ -55,16 +61,23 @@ class CallInfo {
|
||||
else {
|
||||
// outbound call triggered by REST
|
||||
const {req, callSid, accountSid, applicationSid, to, tag} = opts;
|
||||
srf = req.srf;
|
||||
this.callSid = callSid;
|
||||
this.accountSid = accountSid;
|
||||
this.applicationSid = applicationSid;
|
||||
this.callStatus = CallStatus.Trying,
|
||||
this.callId = req.get('Call-ID');
|
||||
this.sipStatus = 100;
|
||||
this.sipReason = 'Trying';
|
||||
this.from = from || req.callingNumber;
|
||||
this.to = to;
|
||||
if (tag) this._customerData = tag;
|
||||
}
|
||||
|
||||
this.localSipAddress = srf.locals.localSipAddress;
|
||||
if (srf.locals.publicIp) {
|
||||
this.publicIp = srf.locals.publicIp;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,9 +85,10 @@ class CallInfo {
|
||||
* @param {string} callStatus - current call status
|
||||
* @param {number} sipStatus - current sip status
|
||||
*/
|
||||
updateCallStatus(callStatus, sipStatus) {
|
||||
updateCallStatus(callStatus, sipStatus, sipReason) {
|
||||
this.callStatus = callStatus;
|
||||
if (sipStatus) this.sipStatus = sipStatus;
|
||||
if (sipReason) this.sipReason = sipReason;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,10 +111,13 @@ class CallInfo {
|
||||
to: this.to,
|
||||
callId: this.callId,
|
||||
sipStatus: this.sipStatus,
|
||||
sipReason: this.sipReason,
|
||||
callStatus: this.callStatus,
|
||||
callerId: this.callerId,
|
||||
accountSid: this.accountSid,
|
||||
applicationSid: this.applicationSid
|
||||
traceId: this.traceId,
|
||||
applicationSid: this.applicationSid,
|
||||
fsSipAddress: this.localSipAddress
|
||||
};
|
||||
['parentCallSid', 'originatingSipIp', 'originatingSipTrunkName'].forEach((prop) => {
|
||||
if (this[prop]) obj[prop] = this[prop];
|
||||
@@ -110,6 +127,13 @@ class CallInfo {
|
||||
if (this._customerData) {
|
||||
Object.assign(obj, {customerData: this._customerData});
|
||||
}
|
||||
|
||||
if (process.env.JAMBONES_API_BASE_URL) {
|
||||
Object.assign(obj, {apiBaseUrl: process.env.JAMBONES_API_BASE_URL});
|
||||
}
|
||||
if (this.publicIp) {
|
||||
Object.assign(obj, {fsPublicIp: this.publicIp});
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,14 +8,18 @@ const CallSession = require('./call-session');
|
||||
|
||||
*/
|
||||
class ConfirmCallSession extends CallSession {
|
||||
constructor({logger, application, dlg, ep, tasks, callInfo}) {
|
||||
constructor({logger, application, dlg, ep, tasks, callInfo, accountInfo, memberId, confName, rootSpan}) {
|
||||
super({
|
||||
logger,
|
||||
application,
|
||||
srf: dlg.srf,
|
||||
callSid: dlg.callSid,
|
||||
tasks,
|
||||
callInfo
|
||||
callInfo,
|
||||
accountInfo,
|
||||
memberId,
|
||||
confName,
|
||||
rootSpan
|
||||
});
|
||||
this.dlg = dlg;
|
||||
this.ep = ep;
|
||||
@@ -27,6 +31,10 @@ class ConfirmCallSession extends CallSession {
|
||||
_clearResources() {
|
||||
}
|
||||
|
||||
_callerHungup() {
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
module.exports = ConfirmCallSession;
|
||||
|
||||
@@ -16,23 +16,36 @@ class InboundCallSession extends CallSession {
|
||||
application: req.locals.application,
|
||||
callInfo: req.locals.callInfo,
|
||||
accountInfo: req.locals.accountInfo,
|
||||
tasks: req.locals.application.tasks
|
||||
tasks: req.locals.application.tasks,
|
||||
rootSpan: req.locals.rootSpan
|
||||
});
|
||||
this.req = req;
|
||||
this.res = res;
|
||||
|
||||
req.on('cancel', () => {
|
||||
this._notifyCallStatusChange({callStatus: CallStatus.NoAnswer, sipStatus: 487});
|
||||
this._callReleased();
|
||||
});
|
||||
req.once('cancel', this._onCancel.bind(this));
|
||||
|
||||
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
|
||||
this._notifyCallStatusChange({callStatus: CallStatus.Trying, sipStatus: 100});
|
||||
this._notifyCallStatusChange({
|
||||
callStatus: CallStatus.Trying,
|
||||
sipStatus: 100,
|
||||
sipReason: 'Trying'
|
||||
});
|
||||
}
|
||||
|
||||
_onCancel() {
|
||||
this.rootSpan.setAttributes({'call.termination': 'caller abandoned'});
|
||||
this._notifyCallStatusChange({
|
||||
callStatus: CallStatus.NoAnswer,
|
||||
sipStatus: 487,
|
||||
sipReason: 'Request Terminated'
|
||||
});
|
||||
this._callReleased();
|
||||
}
|
||||
|
||||
_onTasksDone() {
|
||||
if (!this.res.finalResponseSent) {
|
||||
if (this._mediaServerFailure) {
|
||||
this.rootSpan.setAttributes({'call.termination': 'media server failure'});
|
||||
this.logger.info('InboundCallSession:_onTasksDone generating 480 due to media server failure');
|
||||
this.res.send(480, {
|
||||
headers: {
|
||||
@@ -41,10 +54,12 @@ class InboundCallSession extends CallSession {
|
||||
});
|
||||
}
|
||||
else {
|
||||
this.rootSpan.setAttributes({'call.termination': 'tasks completed without answering call'});
|
||||
this.logger.info('InboundCallSession:_onTasksDone auto-generating non-success response to invite');
|
||||
this.res.send(603);
|
||||
}
|
||||
}
|
||||
this.req.removeAllListeners('cancel');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,9 +68,14 @@ class InboundCallSession extends CallSession {
|
||||
_callerHungup() {
|
||||
assert(this.dlg.connectTime);
|
||||
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
||||
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||
this.logger.debug('InboundCallSession: caller hung up');
|
||||
this.rootSpan.setAttributes({'call.termination': 'hangup by caller'});
|
||||
this.emit('callStatusChange', {
|
||||
callStatus: CallStatus.Completed,
|
||||
duration
|
||||
});
|
||||
this.logger.info('InboundCallSession: caller hung up');
|
||||
this._callReleased();
|
||||
this.req.removeAllListeners('cancel');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ const moment = require('moment');
|
||||
* @extends CallSession
|
||||
*/
|
||||
class RestCallSession extends CallSession {
|
||||
constructor({logger, application, srf, req, ep, tasks, callInfo, accountInfo}) {
|
||||
constructor({logger, application, srf, req, ep, tasks, callInfo, accountInfo, rootSpan}) {
|
||||
super({
|
||||
logger,
|
||||
application,
|
||||
@@ -16,13 +16,18 @@ class RestCallSession extends CallSession {
|
||||
callSid: callInfo.callSid,
|
||||
tasks,
|
||||
callInfo,
|
||||
accountInfo
|
||||
accountInfo,
|
||||
rootSpan
|
||||
});
|
||||
this.req = req;
|
||||
this.ep = ep;
|
||||
|
||||
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
|
||||
this._notifyCallStatusChange({callStatus: CallStatus.Trying, sipStatus: 100});
|
||||
this._notifyCallStatusChange({
|
||||
callStatus: CallStatus.Trying,
|
||||
sipStatus: 100,
|
||||
sipReason: 'Trying'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ const WAIT = 'wait';
|
||||
const JOIN = 'join';
|
||||
const START = 'start';
|
||||
|
||||
|
||||
function confNoMatch(str) {
|
||||
return str.match(/^No active conferences/) || str.match(/Conference.*not found/);
|
||||
}
|
||||
@@ -27,7 +28,8 @@ function camelize(str) {
|
||||
|
||||
function unhandled(logger, cs, evt) {
|
||||
this.participantCount = parseInt(evt.getHeader('Conference-Size'));
|
||||
logger.debug({evt}, `unhandled conference event: ${evt.getHeader('Action')}`) ;
|
||||
// logger.debug({evt}, `unhandled conference event: ${evt.getHeader('Action')}`) ;
|
||||
logger.debug(`unhandled conference event: ${evt.getHeader('Action')}`) ;
|
||||
}
|
||||
|
||||
function capitalize(s) {
|
||||
@@ -45,10 +47,10 @@ class Conference extends Task {
|
||||
|
||||
this.confName = this.data.name;
|
||||
[
|
||||
'beep', 'startConferenceOnEnter', 'endConferenceOnExit',
|
||||
'beep', 'startConferenceOnEnter', 'endConferenceOnExit', 'joinMuted',
|
||||
'maxParticipants', 'waitHook', 'statusHook', 'endHook', 'enterHook'
|
||||
].forEach((attr) => this[attr] = this.data[attr]);
|
||||
|
||||
this.record = this.data.record || {};
|
||||
this.statusEvents = [];
|
||||
if (this.statusHook) {
|
||||
['start', 'end', 'join', 'leave', 'start-talking', 'stop-talking'].forEach((e) => {
|
||||
@@ -67,6 +69,9 @@ class Conference extends Task {
|
||||
|
||||
get name() { return TaskName.Conference; }
|
||||
|
||||
get shouldRecord() { return this.record.path; }
|
||||
get isRecording() { return this.recordingInProgress; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
await super.exec(cs);
|
||||
this.ep = ep;
|
||||
@@ -213,6 +218,7 @@ class Conference extends Task {
|
||||
this._playSession.kill();
|
||||
this._playSession = null;
|
||||
}
|
||||
cs.clearConferenceDetails();
|
||||
resolve();
|
||||
});
|
||||
|
||||
@@ -330,15 +336,30 @@ class Conference extends Task {
|
||||
|
||||
const opts = {};
|
||||
if (this.endConferenceOnExit) Object.assign(opts, {flags: {endconf: true}});
|
||||
if (this.startConferenceOnEnter) Object.assign(opts, {flags: {moderator: true}});
|
||||
if (this.joinMuted) Object.assign(opts, {flags: {mute: true}});
|
||||
|
||||
try {
|
||||
const {memberId, confUuid} = await this.ep.join(this.confName, opts);
|
||||
this.logger.debug({memberId, confUuid}, `Conference:_joinConference: successfully joined ${this.confName}`);
|
||||
this.memberId = memberId;
|
||||
this.confUuid = confUuid;
|
||||
|
||||
cs.setConferenceDetails(memberId, this.confName, confUuid);
|
||||
const response = await this.ep.api('conference', [this.confName, 'get', 'count']);
|
||||
if (response.body && /\d+/.test(response.body)) this.participantCount = parseInt(response.body);
|
||||
this._notifyConferenceEvent(cs, 'join');
|
||||
|
||||
// start recording if requested and we just started the conference
|
||||
if (startConf && this.shouldRecord) {
|
||||
this.logger.info(`recording conference to ${this.record.path}`);
|
||||
try {
|
||||
await this.ep.api(`conference ${this.confName} record ${this.record.path}`);
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Conference:_joinConference - failed to start recording');
|
||||
}
|
||||
}
|
||||
|
||||
// listen for conference events
|
||||
this.ep.filter('Conference-Unique-ID', this.confUuid);
|
||||
this.ep.conn.on('esl::event::CUSTOM::*', this.__onConferenceEvent.bind(this, cs)) ;
|
||||
@@ -356,7 +377,7 @@ class Conference extends Task {
|
||||
}
|
||||
|
||||
if (typeof this.maxParticipants === 'number' && this.maxParticipants > 1) {
|
||||
this.endpoint.api('conference', `${this.confName} set max_members ${this.maxParticipants}`)
|
||||
this.ep.api('conference', `${this.confName} set max_members ${this.maxParticipants}`)
|
||||
.catch((err) => this.logger.error(err, `Error setting max participants to ${this.maxParticipants}`));
|
||||
}
|
||||
}
|
||||
@@ -371,9 +392,70 @@ class Conference extends Task {
|
||||
*/
|
||||
notifyStartConference(cs, opts) {
|
||||
this.logger.info({opts}, `Conference:notifyStartConference: conference ${this.confName} has now started`);
|
||||
this.conferenceStartTime = new Date();
|
||||
this.emitter.emit('join', opts);
|
||||
}
|
||||
|
||||
async doConferenceMuteNonModerators(cs, opts) {
|
||||
const mute = opts.conf_mute_status === 'mute';
|
||||
assert (cs.isInConference);
|
||||
|
||||
this.logger.info(`Conference:doConferenceMuteNonModerators ${mute ? 'muting' : 'unmuting'} non-moderators`);
|
||||
this.ep.api(`conference ${this.confName} ${mute ? 'mute' : 'unmute'} non_moderator`)
|
||||
.catch((err) => this.logger.info({err}, 'Error muting or unmuting non_moderators'));
|
||||
|
||||
if (this.conf_hold_status !== 'hold' && this._playSession) {
|
||||
this._playSession.kill();
|
||||
this._playSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
async doConferenceHold(cs, opts) {
|
||||
assert (cs.isInConference);
|
||||
|
||||
const {conf_hold_status, wait_hook} = opts;
|
||||
let hookOnly = true;
|
||||
|
||||
if (this.conf_hold_status !== conf_hold_status) {
|
||||
hookOnly = false;
|
||||
this.conf_hold_status = conf_hold_status;
|
||||
const hold = conf_hold_status === 'hold';
|
||||
|
||||
this.ep.api(`conference ${this.confName} ${hold ? 'mute' : 'unmute'} ${this.memberId}`)
|
||||
.catch((err) => this.logger.info({err}, 'Error muting or unmuting participant'));
|
||||
this.ep.api(`conference ${this.confName} ${hold ? 'deaf' : 'undeaf'} ${this.memberId}`)
|
||||
.catch((err) => this.logger.info({err}, 'Error deafing or undeafing participant'));
|
||||
}
|
||||
|
||||
if (hookOnly && this._playSession) {
|
||||
this._playSession.kill();
|
||||
this._playSession = null;
|
||||
}
|
||||
if (wait_hook && this.conf_hold_status === 'hold') {
|
||||
const {dlg} = cs;
|
||||
this._doWaitHookWhileOnHold(cs, dlg, wait_hook);
|
||||
}
|
||||
else if (this.conf_hold_status !== 'hold' && this._playSession) {
|
||||
this._playSession.kill();
|
||||
this._playSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
async _doWaitHookWhileOnHold(cs, dlg, wait_hook) {
|
||||
do {
|
||||
try {
|
||||
const tasks = await this._playHook(cs, dlg, wait_hook);
|
||||
if (0 === tasks.length) break;
|
||||
} catch (err) {
|
||||
if (!this.killed) {
|
||||
this.logger.info(err, `Conference:_doWait: failed retrieving wait_hook for ${this.confName}`);
|
||||
}
|
||||
this._playSession = null;
|
||||
break;
|
||||
}
|
||||
} while (!this.killed && this.conf_hold_status !== 'hold');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add ourselves to the waitlist of sessions to be notified once
|
||||
* the conference starts
|
||||
@@ -447,24 +529,30 @@ class Conference extends Task {
|
||||
|
||||
async _playHook(cs, dlg, hook, allowed = [TaskName.Play, TaskName.Say, TaskName.Pause]) {
|
||||
assert(!this._playSession);
|
||||
const json = await cs.application.requestor.request(hook, cs.callInfo);
|
||||
const b3 = this.getTracingPropagation();
|
||||
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 allowedTasks = json.filter((task) => allowed.includes(task.verb));
|
||||
if (json.length !== allowedTasks.length) {
|
||||
this.logger.debug({json, allowedTasks}, 'unsupported task');
|
||||
throw new Error(`unsupported verb in dial conference wait/enterHook: only ${JSON.stringify(allowed)}`);
|
||||
const allowedTasks = tasks.filter((t) => allowed.includes(t.name));
|
||||
if (tasks.length !== allowedTasks.length) {
|
||||
this.logger.debug({tasks, allowedTasks}, 'unsupported task');
|
||||
throw new Error(`unsupported verb in conference waitHook: only ${JSON.stringify(allowed)}`);
|
||||
}
|
||||
this.logger.debug(`Conference:_playHook: executing ${json.length} tasks`);
|
||||
this.logger.debug(`Conference:_playHook: executing ${tasks.length} tasks`);
|
||||
|
||||
if (json.length > 0) {
|
||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
if (tasks.length > 0) {
|
||||
this._playSession = new ConfirmCallSession({
|
||||
logger: this.logger,
|
||||
application: cs.application,
|
||||
dlg,
|
||||
ep: cs.ep,
|
||||
callInfo: cs.callInfo,
|
||||
tasks
|
||||
accountInfo: cs.accountInfo,
|
||||
memberId: this.memberId,
|
||||
confName: this.confName,
|
||||
tasks,
|
||||
rootSpan: cs.rootSpan
|
||||
});
|
||||
await this._playSession.exec();
|
||||
this._playSession = null;
|
||||
@@ -484,6 +572,7 @@ class Conference extends Task {
|
||||
}
|
||||
|
||||
async replaceEndpointAndEnd(cs) {
|
||||
cs.clearConferenceDetails();
|
||||
if (this.replaced) return;
|
||||
this.replaced = true;
|
||||
try {
|
||||
@@ -496,11 +585,14 @@ class Conference extends Task {
|
||||
|
||||
_notifyConferenceEvent(cs, eventName, params = {}) {
|
||||
if (this.statusEvents.includes(eventName)) {
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
params.event = eventName;
|
||||
params.duration = (Date.now() - this.conferenceStartTime.getTime()) / 1000;
|
||||
if (!params.time) params.time = (new Date()).toISOString();
|
||||
if (!params.members && typeof this.participantCount === 'number') params.members = this.participantCount;
|
||||
cs.application.requestor.request(this.statusHook, Object.assign(params, this.statusParams))
|
||||
cs.application.requestor
|
||||
.request('verb:hook', this.statusHook, Object.assign(params, this.statusParams, httpHeaders))
|
||||
.catch((err) => this.logger.info(err, 'Conference:notifyConferenceEvent - error'));
|
||||
}
|
||||
}
|
||||
@@ -514,9 +606,6 @@ class Conference extends Task {
|
||||
const functionName = `_on${capitalize(camelize(action))}`;
|
||||
(Conference.prototype[functionName] || unhandled).bind(this, this.logger, cs, evt)() ;
|
||||
}
|
||||
else {
|
||||
this.logger.debug(`Conference#__onConferenceEvent: got unhandled custom event: ${eventName}`) ;
|
||||
}
|
||||
}
|
||||
|
||||
// conference event handlers
|
||||
|
||||
112
lib/tasks/config.js
Normal file
112
lib/tasks/config.js
Normal file
@@ -0,0 +1,112 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
|
||||
class TaskConfig extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
[
|
||||
'synthesizer',
|
||||
'recognizer',
|
||||
'bargeIn',
|
||||
'record'
|
||||
].forEach((k) => this[k] = this.data[k] || {});
|
||||
|
||||
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 ? 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}`);
|
||||
}
|
||||
return `${this.name}{${phrase.join(',')}`;
|
||||
}
|
||||
|
||||
async exec(cs) {
|
||||
await super.exec(cs);
|
||||
|
||||
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;
|
||||
}
|
||||
this.logger.info({
|
||||
recognizer: this.recognizer,
|
||||
isContinuousAsr: cs.isContinuousAsr
|
||||
}, 'Config: updated recognizer');
|
||||
}
|
||||
if ('enable' in this.bargeIn) {
|
||||
if (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 {
|
||||
this.logger.info('Config: disabling bargeIn');
|
||||
cs.disableBotMode();
|
||||
}
|
||||
}
|
||||
if (this.record) cs.notifyRecordOptions(this.record);
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskConfig;
|
||||
@@ -110,7 +110,8 @@ class TaskDequeue extends Task {
|
||||
event: 'dequeue',
|
||||
dequeueSipAddress: cs.srf.locals.localSipAddress,
|
||||
epUuid: ep.uuid,
|
||||
notifyUrl: getUrl(cs)
|
||||
notifyUrl: getUrl(cs),
|
||||
dequeuer: cs.callInfo.toJSON()
|
||||
});
|
||||
this.logger.info(`TaskDequeue:_dequeueUrl successfully sent POST to ${url}`);
|
||||
bridgeTimer = setTimeout(() => reject(new Error('bridge timeout')), 20000);
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
const Task = require('./task');
|
||||
const makeTask = require('./make_task');
|
||||
const {CallStatus, CallDirection, TaskName, TaskPreconditions, MAX_SIMRINGS} = require('../utils/constants');
|
||||
const {
|
||||
CallStatus,
|
||||
CallDirection,
|
||||
TaskName,
|
||||
TaskPreconditions,
|
||||
MAX_SIMRINGS,
|
||||
KillReason
|
||||
} = require('../utils/constants');
|
||||
const assert = require('assert');
|
||||
const placeCall = require('../utils/place-outdial');
|
||||
const sessionTracker = require('../session/session-tracker');
|
||||
const DtmfCollector = require('../utils/dtmf-collector');
|
||||
const dbUtils = require('../utils/db-utils');
|
||||
const debug = require('debug')('jambonz:feature-server');
|
||||
const {parseUri} = require('drachtio-srf');
|
||||
|
||||
function parseDtmfOptions(logger, dtmfCapture) {
|
||||
let parentDtmfCollector, childDtmfCollector;
|
||||
@@ -83,6 +92,7 @@ class TaskDial extends Task {
|
||||
this.timeLimit = this.data.timeLimit;
|
||||
this.confirmHook = this.data.confirmHook;
|
||||
this.confirmMethod = this.data.confirmMethod;
|
||||
this.referHook = this.data.referHook;
|
||||
this.dtmfHook = this.data.dtmfHook;
|
||||
this.proxy = this.data.proxy;
|
||||
|
||||
@@ -123,6 +133,28 @@ class TaskDial extends Task {
|
||||
|
||||
get name() { return TaskName.Dial; }
|
||||
|
||||
get canReleaseMedia() {
|
||||
return !process.env.ANCHOR_MEDIA_ALWAYS && !this.listenTask && !this.transcribeTask;
|
||||
}
|
||||
|
||||
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) {
|
||||
await super.exec(cs);
|
||||
try {
|
||||
@@ -135,31 +167,44 @@ class TaskDial extends Task {
|
||||
this.epOther.play(this.dialMusic).catch((err) => {});
|
||||
}
|
||||
}
|
||||
if (this.epOther) this._installDtmfDetection(cs, this.epOther, this.parentDtmfCollector);
|
||||
await this._attemptCalls(cs);
|
||||
if (!this.killed) await this._attemptCalls(cs);
|
||||
await this.awaitTaskDone();
|
||||
await this.performAction(this.results);
|
||||
if (this.epOther) this._removeDtmfDetection(cs, this.epOther);
|
||||
this._removeDtmfDetection(cs, this.ep);
|
||||
this.logger.debug({callSid: this.cs.callSid}, 'Dial:exec task is done, sending actionHook if any');
|
||||
await this.performAction(this.results, this.killReason !== KillReason.Replaced);
|
||||
this._removeDtmfDetection(cs.dlg);
|
||||
this._removeDtmfDetection(this.dlg);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'TaskDial:exec terminating with error');
|
||||
this.kill(cs);
|
||||
}
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
async kill(cs, reason) {
|
||||
super.kill(cs);
|
||||
if (this.epOther) this._removeDtmfDetection(this.cs, this.epOther);
|
||||
this._removeDtmfDetection(this.cs, this.ep);
|
||||
if (this.dialMusic && this.epOther) {
|
||||
this.epOther.api('uuid_break', this.epOther.uuid)
|
||||
.catch((err) => this.logger.info(err, 'Error killing dialMusic'));
|
||||
}
|
||||
this.killReason = reason || KillReason.Hangup;
|
||||
if (this.timerMaxCallDuration) {
|
||||
clearTimeout(this.timerMaxCallDuration);
|
||||
this.timerMaxCallDuration = null;
|
||||
}
|
||||
if (this.timerRing) {
|
||||
clearTimeout(this.timerRing);
|
||||
this.timerRing = null;
|
||||
}
|
||||
this._removeDtmfDetection(cs.dlg);
|
||||
this._removeDtmfDetection(this.dlg);
|
||||
this._killOutdials();
|
||||
if (this.sd) {
|
||||
this.sd.kill();
|
||||
this.sd.removeAllListeners();
|
||||
this.sd = null;
|
||||
}
|
||||
if (this.callSid) sessionTracker.remove(this.callSid);
|
||||
if (this.listenTask) await this.listenTask.kill(cs);
|
||||
if (this.transcribeTask) await this.transcribeTask.kill(cs);
|
||||
if (this.timerMaxCallDuration) clearTimeout(this.timerMaxCallDuration);
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
@@ -168,18 +213,32 @@ class TaskDial extends Task {
|
||||
* @param {*} tasks - array of play/say tasks to execute
|
||||
*/
|
||||
async whisper(tasks, callSid) {
|
||||
if (!this.epOther || !this.ep) return this.logger.info('Dial:whisper: no paired endpoint found');
|
||||
try {
|
||||
const cs = this.callSession;
|
||||
if (!this.ep && !this.epOther) {
|
||||
await this.reAnchorMedia(this.callSession, this.sd);
|
||||
}
|
||||
|
||||
if (!this.epOther || !this.ep) return this.logger.info('Dial:whisper: no paired endpoint found');
|
||||
|
||||
this.logger.debug('Dial:whisper unbridging endpoints');
|
||||
await this.epOther.unbridge();
|
||||
this.logger.debug('Dial:whisper executing tasks');
|
||||
while (tasks.length && !cs.callGone) {
|
||||
const task = tasks.shift();
|
||||
const {span, ctx} = this.startChildSpan(`whisper:${this.sayTask.summary}`);
|
||||
task.span = span;
|
||||
task.ctx = ctx;
|
||||
await task.exec(cs, callSid === this.callSid ? this.ep : this.epOther);
|
||||
span.end();
|
||||
}
|
||||
this.logger.debug('Dial:whisper tasks complete');
|
||||
if (!cs.callGone && this.epOther) this.epOther.bridge(this.ep);
|
||||
if (!cs.callGone && this.epOther) {
|
||||
|
||||
/* if we can release the media back to the SBC, do so now */
|
||||
if (this.canReleaseMedia) this._releaseMedia(cs, this.sd);
|
||||
else this.epOther.bridge(this.ep);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'Dial:whisper error');
|
||||
}
|
||||
@@ -189,54 +248,111 @@ class TaskDial extends Task {
|
||||
* mute or unmute one side of the call
|
||||
*/
|
||||
async mute(callSid, doMute) {
|
||||
if (!this.epOther || !this.ep) return this.logger.info('Dial:mute: no paired endpoint found');
|
||||
const parentCall = callSid !== this.callSid;
|
||||
const dlg = parentCall ? this.callSession.dlg : this.dlg;
|
||||
const hdr = `${doMute ? 'mute' : 'unmute'} call leg`;
|
||||
try {
|
||||
const parentCall = callSid !== this.callSid;
|
||||
const ep = parentCall ? this.epOther : this.ep;
|
||||
await ep[doMute ? 'mute' : 'unmute']();
|
||||
this.logger.debug(`Dial:mute ${doMute ? 'muted' : 'unmuted'} ${parentCall ? 'parentCall' : 'childCall'}`);
|
||||
/* let rtpengine do the mute / unmute */
|
||||
await dlg.request({
|
||||
method: 'INFO',
|
||||
headers: {
|
||||
'X-Reason': hdr
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'Dial:mute error');
|
||||
this.logger.info({err}, `Dial:mute - ${hdr} error`);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
sd.removeAllListeners('accept');
|
||||
sd.removeAllListeners('decline');
|
||||
sd.removeAllListeners('adulting');
|
||||
sd.removeAllListeners('callStatusChange');
|
||||
sd.removeAllListeners('callCreateFail');
|
||||
}
|
||||
|
||||
_killOutdials() {
|
||||
for (const [callSid, sd] of Array.from(this.dials)) {
|
||||
this.logger.debug(`Dial:_killOutdials killing callSid ${callSid}`);
|
||||
sd.kill().catch((err) => this.logger.info(err, `Dial:_killOutdials Error killing ${callSid}`));
|
||||
this._removeHandlers(sd);
|
||||
}
|
||||
this.dials.clear();
|
||||
}
|
||||
|
||||
_installDtmfDetection(cs, ep, dtmfDetector) {
|
||||
if (ep && this.dtmfHook && !ep.dtmfDetector) {
|
||||
ep.dtmfDetector = dtmfDetector;
|
||||
ep.on('dtmf', this._onDtmf.bind(this, cs, ep));
|
||||
}
|
||||
_installDtmfDetection(cs, dlg) {
|
||||
dlg.on('info', this._onInfo.bind(this, cs, dlg));
|
||||
}
|
||||
_removeDtmfDetection(cs, ep) {
|
||||
if (ep) {
|
||||
delete ep.dtmfDetector;
|
||||
ep.removeAllListeners('dtmf');
|
||||
}
|
||||
_removeDtmfDetection(dlg) {
|
||||
dlg && dlg.removeAllListeners('info');
|
||||
}
|
||||
|
||||
_onDtmf(cs, ep, evt) {
|
||||
if (ep.dtmfDetector) {
|
||||
const match = ep.dtmfDetector.keyPress(evt.dtmf);
|
||||
if (match) {
|
||||
this.logger.debug({callSid: this.cs.callSid}, `Dial:_onDtmf triggered dtmf match: ${match}`);
|
||||
const requestor = ep.dtmfDetector === this.parentDtmfCollector ?
|
||||
cs.requestor :
|
||||
(this.sd ? this.sd.requestor : null);
|
||||
if (!requestor) {
|
||||
this.logger.info(`Dial:_onDtmf got digits on B leg after adulting: ${evt.dtmf}`);
|
||||
}
|
||||
else {
|
||||
requestor.request(this.dtmfHook, {dtmf: match, ...cs.callInfo})
|
||||
.catch((err) => this.logger.info(err, 'Dial:_onDtmf - error'));
|
||||
}
|
||||
}
|
||||
_onInfo(cs, dlg, req, res) {
|
||||
res.send(200);
|
||||
if (req.get('Content-Type') !== 'application/dtmf-relay') return;
|
||||
|
||||
const dtmfDetector = dlg === cs.dlg ? this.parentDtmfCollector : this.childDtmfCollector;
|
||||
if (!dtmfDetector) return;
|
||||
let requestor, callSid, callInfo;
|
||||
if (dtmfDetector === this.parentDtmfCollector) {
|
||||
requestor = cs.requestor;
|
||||
callSid = cs.callSid;
|
||||
callInfo = cs.callInfo;
|
||||
}
|
||||
else {
|
||||
requestor = this.sd?.requestor;
|
||||
callSid = this.sd?.callSid;
|
||||
callInfo = this.sd?.callInfo;
|
||||
}
|
||||
if (!requestor) return;
|
||||
const arr = /Signal=([0-9#*])/.exec(req.body);
|
||||
if (!arr) return;
|
||||
const key = arr[1];
|
||||
const match = dtmfDetector.keyPress(key);
|
||||
if (match) {
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
this.logger.info({callSid}, `Dial:_onInfo triggered dtmf match: ${match}`);
|
||||
requestor.request('verb:hook', this.dtmfHook, {dtmf: match, ...callInfo.toJSON()}, httpHeaders)
|
||||
.catch((err) => this.logger.info(err, 'Dial:_onDtmf - error'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +362,7 @@ class TaskDial extends Task {
|
||||
debug(`Dial:__initializeInbound allocated ep for incoming call: ${ep.uuid}`);
|
||||
|
||||
/* send outbound legs back to the same SBC (to support static IP feature) */
|
||||
if (!this.proxy) this.proxy = `${cs.req.source_address}:${cs.req.source_port};transport=tcp`;
|
||||
if (!this.proxy) this.proxy = `${cs.req.source_address}:${cs.req.source_port}`;
|
||||
|
||||
if (this.dialMusic) {
|
||||
// play dial music to caller while we outdial
|
||||
@@ -260,6 +376,7 @@ class TaskDial extends Task {
|
||||
const {req, srf} = cs;
|
||||
const {getSBC} = srf.locals;
|
||||
const {lookupTeamsByAccount, lookupAccountBySid} = srf.locals.dbHelpers;
|
||||
const {lookupCarrier} = dbUtils(this.logger, cs.srf);
|
||||
const sbcAddress = this.proxy || getSBC();
|
||||
const teamsInfo = {};
|
||||
let fqdn;
|
||||
@@ -283,15 +400,17 @@ class TaskDial extends Task {
|
||||
}
|
||||
|
||||
const ms = await cs.getMS();
|
||||
const timerRing = setTimeout(() => {
|
||||
this.timerRing = setTimeout(() => {
|
||||
this.logger.info(`Dial:_attemptCall: ring no answer timer ${this.timeout}s exceeded`);
|
||||
this.timerRing = null;
|
||||
this._killOutdials();
|
||||
}, this.timeout * 1000);
|
||||
|
||||
this.span.setAttributes({'dial.target': JSON.stringify(this.target)});
|
||||
this.target.forEach(async(t) => {
|
||||
try {
|
||||
t.url = t.url || this.confirmUrl;
|
||||
t.method = t.method || this.confirmMethod || 'POST';
|
||||
t.confirmHook = t.confirmHook || this.confirmHook;
|
||||
//t.method = t.method || this.confirmMethod || 'POST';
|
||||
if (t.type === 'teams') t.teamsInfo = teamsInfo;
|
||||
if (t.type === 'user' && !t.name.includes('@') && !fqdn) {
|
||||
const user = t.name;
|
||||
@@ -305,6 +424,16 @@ class TaskDial extends Task {
|
||||
this.logger.error({err}, 'Error looking up account by sid');
|
||||
}
|
||||
}
|
||||
if (t.type === 'phone' && t.trunk) {
|
||||
const voip_carrier_sid = await lookupCarrier(cs.accountSid, t.trunk);
|
||||
this.logger.info(`Dial:_attemptCalls: selected ${voip_carrier_sid} for requested carrier: ${t.trunk})`);
|
||||
if (voip_carrier_sid) {
|
||||
opts.headers['X-Requested-Carrier-Sid'] = voip_carrier_sid;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.killed) return;
|
||||
|
||||
const sd = placeCall({
|
||||
logger: this.logger,
|
||||
application: cs.application,
|
||||
@@ -313,13 +442,19 @@ class TaskDial extends Task {
|
||||
sbcAddress,
|
||||
target: t,
|
||||
opts,
|
||||
callInfo: cs.callInfo
|
||||
callInfo: cs.callInfo,
|
||||
accountInfo: cs.accountInfo,
|
||||
rootSpan: cs.rootSpan,
|
||||
startSpan: this.startSpan.bind(this)
|
||||
});
|
||||
this.dials.set(sd.callSid, sd);
|
||||
|
||||
sd
|
||||
.on('refer', (callInfo, req, res) => this.handleRefer(cs, req, res, callInfo))
|
||||
.on('callCreateFail', () => {
|
||||
clearTimeout(this.timerRing);
|
||||
this.dials.delete(sd.callSid);
|
||||
sd.removeAllListeners();
|
||||
if (this.dials.size === 0 && !this.sd) {
|
||||
this.logger.debug('Dial:_attemptCalls - all calls failed after call create err, ending task');
|
||||
this.kill(cs);
|
||||
@@ -343,7 +478,8 @@ class TaskDial extends Task {
|
||||
break;
|
||||
case CallStatus.InProgress:
|
||||
this.logger.debug('Dial:_attemptCall -- call was answered');
|
||||
clearTimeout(timerRing);
|
||||
clearTimeout(this.timerRing);
|
||||
this.timerRing = null;
|
||||
break;
|
||||
case CallStatus.Failed:
|
||||
case CallStatus.Busy:
|
||||
@@ -351,23 +487,41 @@ class TaskDial extends Task {
|
||||
this.dials.delete(sd.callSid);
|
||||
if (this.dials.size === 0 && !this.sd) {
|
||||
this.logger.debug('Dial:_attemptCalls - all calls failed after call failure, ending task');
|
||||
clearTimeout(timerRing);
|
||||
clearTimeout(this.timerRing);
|
||||
this.timerRing = null;
|
||||
this.kill(cs);
|
||||
}
|
||||
break;
|
||||
}
|
||||
})
|
||||
.on('accept', () => {
|
||||
.on('accept', async() => {
|
||||
this.logger.debug(`Dial:_attemptCalls - we have a winner: ${sd.callSid}`);
|
||||
this._connectSingleDial(cs, sd);
|
||||
clearTimeout(this.timerRing);
|
||||
try {
|
||||
await this._connectSingleDial(cs, sd);
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Dial:_attemptCalls - Error calling _connectSingleDial ');
|
||||
}
|
||||
})
|
||||
.on('decline', () => {
|
||||
this.logger.debug(`Dial:_attemptCalls - declined: ${sd.callSid}`);
|
||||
clearTimeout(this.timerRing);
|
||||
this.dials.delete(sd.callSid);
|
||||
sd.removeAllListeners();
|
||||
if (this.dials.size === 0 && !this.sd) {
|
||||
this.logger.debug('Dial:_attemptCalls - all calls failed after decline, ending task');
|
||||
this.kill(cs);
|
||||
}
|
||||
})
|
||||
.on('reinvite', (req, res) => {
|
||||
try {
|
||||
cs.handleReinviteAfterMediaReleased(req, res);
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'Error in dial einvite from B leg');
|
||||
}
|
||||
})
|
||||
.on('refer', (callInfo, req, res) => {
|
||||
|
||||
})
|
||||
.once('adulting', () => {
|
||||
/* child call just adulted and got its own session */
|
||||
@@ -385,8 +539,8 @@ class TaskDial extends Task {
|
||||
});
|
||||
}
|
||||
|
||||
_connectSingleDial(cs, sd) {
|
||||
if (!this.bridged) {
|
||||
async _connectSingleDial(cs, sd) {
|
||||
if (!this.bridged && !this.canReleaseMedia) {
|
||||
this.logger.debug('Dial:_connectSingleDial bridging endpoints');
|
||||
if (this.epOther) {
|
||||
this.epOther.api('uuid_break', this.epOther.uuid);
|
||||
@@ -396,10 +550,16 @@ class TaskDial extends Task {
|
||||
}
|
||||
|
||||
// ding! ding! ding! we have a winner
|
||||
this._selectSingleDial(cs, sd);
|
||||
await this._selectSingleDial(cs, sd);
|
||||
this._killOutdials(); // NB: order is important
|
||||
}
|
||||
|
||||
_onMaxCallDuration(cs) {
|
||||
this.logger.info(`Dial:_onMaxCallDuration tearing down call as it has reached ${this.timeLimit}s`);
|
||||
this.ep && this.ep.unbridge();
|
||||
this.kill(cs);
|
||||
}
|
||||
|
||||
/**
|
||||
* We now have a call leg produced by the Dial action, so
|
||||
* - hangup any simrings in progress
|
||||
@@ -409,7 +569,7 @@ class TaskDial extends Task {
|
||||
* - launch any nested tasks
|
||||
* - and establish a handler to clean up if the called party hangs up
|
||||
*/
|
||||
_selectSingleDial(cs, sd) {
|
||||
async _selectSingleDial(cs, sd) {
|
||||
debug(`Dial:_selectSingleDial ep for outbound call: ${sd.ep.uuid}`);
|
||||
this.dials.delete(sd.callSid);
|
||||
|
||||
@@ -417,14 +577,10 @@ class TaskDial extends Task {
|
||||
this.callSid = sd.callSid;
|
||||
if (this.earlyMedia) {
|
||||
debug('Dial:_selectSingleDial propagating answer supervision on A leg now that B is connected');
|
||||
cs.propagateAnswer();
|
||||
await cs.propagateAnswer();
|
||||
}
|
||||
if (this.timeLimit) {
|
||||
this.timerMaxCallDuration = setTimeout(() => {
|
||||
this.logger.info(`Dial:_selectSingleDial tearing down call as it has reached ${this.timeLimit}s`);
|
||||
this.ep.unbridge();
|
||||
this.kill(cs);
|
||||
}, this.timeLimit * 1000);
|
||||
this.timerMaxCallDuration = setTimeout(this._onMaxCallDuration.bind(this, cs), this.timeLimit * 1000);
|
||||
}
|
||||
sessionTracker.add(this.callSid, cs);
|
||||
this.dlg.on('destroy', () => {
|
||||
@@ -432,8 +588,11 @@ class TaskDial extends Task {
|
||||
if (this.dlg) {
|
||||
this.logger.debug('Dial:_selectSingleDial called party hungup, ending dial operation');
|
||||
sessionTracker.remove(this.callSid);
|
||||
if (this.timerMaxCallDuration) clearTimeout(this.timerMaxCallDuration);
|
||||
this.ep.unbridge();
|
||||
if (this.timerMaxCallDuration) {
|
||||
clearTimeout(this.timerMaxCallDuration);
|
||||
this.timerMaxCallDuration = null;
|
||||
}
|
||||
this.ep && this.ep.unbridge();
|
||||
this.kill(cs);
|
||||
}
|
||||
});
|
||||
@@ -444,10 +603,14 @@ class TaskDial extends Task {
|
||||
dialCallSid: sd.callSid,
|
||||
});
|
||||
|
||||
if (this.childDtmfCollector) this._installDtmfDetection(cs, this.ep, this.childDtmfCollector);
|
||||
if (this.parentDtmfCollector) this._installDtmfDetection(cs, cs.dlg);
|
||||
if (this.childDtmfCollector) this._installDtmfDetection(cs, this.dlg);
|
||||
|
||||
if (this.transcribeTask) this.transcribeTask.exec(cs, this.ep);
|
||||
if (this.listenTask) this.listenTask.exec(cs, this.ep);
|
||||
if (this.transcribeTask) this.transcribeTask.exec(cs, this.epOther, this.ep);
|
||||
if (this.listenTask) this.listenTask.exec(cs, this.epOther);
|
||||
|
||||
/* if we can release the media back to the SBC, do so now */
|
||||
if (this.canReleaseMedia) this._releaseMedia(cs, sd);
|
||||
}
|
||||
|
||||
_bridgeEarlyMedia(sd) {
|
||||
@@ -459,6 +622,38 @@ class TaskDial extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the media from freeswitch
|
||||
* @param {*} cs
|
||||
* @param {*} sd
|
||||
*/
|
||||
async _releaseMedia(cs, sd) {
|
||||
assert(cs.ep && sd.ep);
|
||||
|
||||
try {
|
||||
const aLegSdp = cs.ep.remote.sdp;
|
||||
const bLegSdp = sd.dlg.remote.sdp;
|
||||
await Promise.all[sd.releaseMediaToSBC(aLegSdp, cs.ep.local.sdp), cs.releaseMediaToSBC(bLegSdp)];
|
||||
this.epOther = null;
|
||||
this.logger.info('Dial:_releaseMedia - successfully released media from freewitch');
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Dial:_releaseMedia error');
|
||||
}
|
||||
}
|
||||
|
||||
async reAnchorMedia(cs, sd) {
|
||||
if (cs.ep && sd.ep) return;
|
||||
|
||||
this.logger.info('Dial:reAnchorMedia - re-anchoring media to freewitch');
|
||||
await Promise.all([sd.reAnchorMedia(), cs.reAnchorMedia()]);
|
||||
this.epOther = cs.ep;
|
||||
}
|
||||
|
||||
async handleReinviteAfterMediaReleased(req, res) {
|
||||
const sdp = await this.dlg.modify(req.body);
|
||||
this.logger.info({sdp}, 'Dial:handleReinviteAfterMediaReleased - sent reinvite to B leg');
|
||||
res.send(200, {body: sdp});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskDial;
|
||||
|
||||
@@ -9,10 +9,22 @@ class Dialogflow extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
this.credentials = this.data.credentials;
|
||||
if (this.data.environment) this.project = `${this.data.project}:${this.data.environment}`;
|
||||
else this.project = this.data.project;
|
||||
|
||||
/* set project id with environment and region (optionally) */
|
||||
if (this.data.environment && this.data.region) {
|
||||
this.project = `${this.data.project}:${this.data.environment}:${this.data.region}`;
|
||||
}
|
||||
else if (this.data.environment) {
|
||||
this.project = `${this.data.project}:${this.data.environment}`;
|
||||
}
|
||||
else if (this.data.region) {
|
||||
this.project = `${this.data.project}::${this.data.region}`;
|
||||
}
|
||||
else {
|
||||
this.project = this.data.project;
|
||||
}
|
||||
|
||||
this.lang = this.data.lang || 'en-US';
|
||||
this.welcomeEvent = this.data.welcomeEvent || '';
|
||||
if (this.welcomeEvent.length && this.data.welcomeEventParams && typeof this.data.welcomeEventParams === 'object') {
|
||||
@@ -198,6 +210,7 @@ class Dialogflow extends Task {
|
||||
/* if we are using tts and a message was provided, play it out */
|
||||
if (this.vendor && intent.fulfillmentText && intent.fulfillmentText.length > 0) {
|
||||
const {srf} = cs;
|
||||
const {stats} = srf.locals;
|
||||
const {synthAudio} = srf.locals.dbHelpers;
|
||||
this.waitingForPlayStart = false;
|
||||
|
||||
@@ -217,7 +230,7 @@ class Dialogflow extends Task {
|
||||
credentials: this.ttsCredentials
|
||||
};
|
||||
this.logger.debug({obj}, 'Dialogflow:_onIntent - playing message via tts');
|
||||
const {filePath, servedFromCache} = await synthAudio(obj);
|
||||
const {filePath, servedFromCache} = await synthAudio(stats, obj);
|
||||
if (filePath) cs.trackTmpFile(filePath);
|
||||
if (!this.ttsCredentials && !servedFromCache) cs.billForTts(intent.fulfillmentText.length);
|
||||
|
||||
@@ -282,9 +295,9 @@ class Dialogflow extends Task {
|
||||
}
|
||||
|
||||
// if a final transcription, start a typing sound
|
||||
if (this.thinkingSound > 0 && !transcription.isEmpty && transcription.isFinal &&
|
||||
if (this.thinkingMusic && !transcription.isEmpty && transcription.isFinal &&
|
||||
transcription.confidence > 0.8) {
|
||||
ep.play(this.data.thinkingSound).catch((err) => this.logger.info(err, 'Error playing typing sound'));
|
||||
ep.play(this.data.thinkingMusic).catch((err) => this.logger.info(err, 'Error playing typing sound'));
|
||||
}
|
||||
|
||||
// interrupt playback on speaking if bargein = true
|
||||
@@ -392,8 +405,8 @@ class Dialogflow extends Task {
|
||||
this.dtmfEntry = dtmfEntry;
|
||||
this.digitBuffer = null;
|
||||
// if a final transcription, start a typing sound
|
||||
if (this.thinkingSound > 0) {
|
||||
ep.play(this.thinkingSound).catch((err) => this.logger.info(err, 'Error playing typing sound'));
|
||||
if (this.thinkingMusic) {
|
||||
ep.play(this.thinkingMusic).catch((err) => this.logger.info(err, 'Error playing typing sound'));
|
||||
}
|
||||
|
||||
// kill the current dialogflow, which will result in us getting an immediate intent
|
||||
@@ -439,8 +452,11 @@ class Dialogflow extends Task {
|
||||
this.noinputTimer = setTimeout(this._onNoInput.bind(this, ep, cs), this.noInputTimeout);
|
||||
}
|
||||
|
||||
async _performHook(cs, hook, results) {
|
||||
const json = await this.cs.requestor.request(hook, results);
|
||||
async _performHook(cs, hook, results = {}) {
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
const json = await this.cs.requestor.request('verb:hook', hook,
|
||||
{...results, ...cs.callInfo.toJSON()}, httpHeaders);
|
||||
if (json && Array.isArray(json)) {
|
||||
const makeTask = require('../make_task');
|
||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
|
||||
@@ -3,7 +3,7 @@ const Emitter = require('events');
|
||||
const ConfirmCallSession = require('../session/confirm-call-session');
|
||||
const normalizeJambones = require('../utils/normalize-jambones');
|
||||
const makeTask = require('./make_task');
|
||||
const {TaskName, TaskPreconditions, QueueResults} = require('../utils/constants');
|
||||
const {TaskName, TaskPreconditions, QueueResults, KillReason} = require('../utils/constants');
|
||||
const bent = require('bent');
|
||||
const assert = require('assert');
|
||||
|
||||
@@ -61,10 +61,11 @@ class TaskEnqueue extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
async kill(cs, reason) {
|
||||
super.kill(cs);
|
||||
this.logger.info(`TaskEnqueue:kill ${this.queueName}`);
|
||||
this.emitter.emit('kill');
|
||||
this.killReason = reason || KillReason.Hangup;
|
||||
this.logger.info(`TaskEnqueue:kill ${this.queueName} with reason ${this.killReason}`);
|
||||
this.emitter.emit('kill', reason || KillReason.Hangup);
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
@@ -76,11 +77,22 @@ class TaskEnqueue extends Task {
|
||||
const members = await pushBack(this.queueName, url);
|
||||
this.logger.info(`TaskEnqueue:_addToQueue: added to queue, length now ${members}`);
|
||||
this.notifyUrl = url;
|
||||
|
||||
/* invoke account-level webhook for queue event notifications */
|
||||
try {
|
||||
cs.performQueueWebhook({
|
||||
event: 'join',
|
||||
queue: this.data.name,
|
||||
length: members,
|
||||
joinTime: this.waitStartTime
|
||||
});
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
async _removeFromQueue(cs, dlg) {
|
||||
const {removeFromList} = cs.srf.locals.dbHelpers;
|
||||
return await removeFromList(this.queueName, getUrl(cs));
|
||||
async _removeFromQueue(cs) {
|
||||
const {removeFromList, lengthOfList} = cs.srf.locals.dbHelpers;
|
||||
await removeFromList(this.queueName, getUrl(cs));
|
||||
return await lengthOfList(this.queueName);
|
||||
}
|
||||
|
||||
async performAction() {
|
||||
@@ -89,7 +101,7 @@ class TaskEnqueue extends Task {
|
||||
queueTime: getElapsedTime(this.waitStartTime),
|
||||
queueResult: this.state
|
||||
};
|
||||
await super.performAction(params);
|
||||
await super.performAction(params, this.killReason !== KillReason.Replaced);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,13 +116,28 @@ class TaskEnqueue extends Task {
|
||||
this.bridgeDetails = opts;
|
||||
this.logger.info({bridgeDetails: this.bridgeDetails}, `time to dequeue from ${this.queueName}`);
|
||||
if (this._playSession) {
|
||||
this._leave = false;
|
||||
this._playSession.kill();
|
||||
this._playSession = null;
|
||||
}
|
||||
resolve(this._doBridge(cs, dlg, ep));
|
||||
})
|
||||
.once('kill', () => {
|
||||
this._removeFromQueue(cs);
|
||||
.once('kill', async() => {
|
||||
|
||||
/* invoke account-level webhook for queue event notifications */
|
||||
if (!this.dequeued) {
|
||||
try {
|
||||
const members = await this._removeFromQueue(cs);
|
||||
cs.performQueueWebhook({
|
||||
event: 'leave',
|
||||
queue: this.data.name,
|
||||
length: members,
|
||||
leaveReason: 'abandoned',
|
||||
leaveTime: Date.now()
|
||||
});
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
if (this._playSession) {
|
||||
this.logger.debug('killing waitUrl');
|
||||
this._playSession.kill();
|
||||
@@ -209,14 +236,16 @@ class TaskEnqueue extends Task {
|
||||
});
|
||||
|
||||
// resolve when either side hangs up
|
||||
this.state = QueueResults.Bridged;
|
||||
this.emitter
|
||||
.on('hangup', () => {
|
||||
this.logger.info('TaskEnqueue:_bridgeLocal ending with hangup from dequeue party');
|
||||
ep.unbridge().catch((err) => {});
|
||||
resolve();
|
||||
})
|
||||
.on('kill', () => {
|
||||
this.logger.info('TaskEnqueue:_bridgeLocal ending with hangup from enqeue party');
|
||||
.on('kill', (reason) => {
|
||||
this.killReason = reason;
|
||||
this.logger.info(`TaskEnqueue:_bridgeLocal ending with ${this.killReason}`);
|
||||
ep.unbridge().catch((err) => {});
|
||||
|
||||
// notify partner that we dropped
|
||||
@@ -242,12 +271,26 @@ class TaskEnqueue extends Task {
|
||||
* @param {string} opts.epUuid uuid of the endpoint we need to bridge to
|
||||
* @param {string} opts.dequeueSipAddress ip:port of the feature server hosting the other call
|
||||
*/
|
||||
notifyQueueEvent(cs, opts) {
|
||||
async notifyQueueEvent(cs, opts) {
|
||||
if (opts.event === 'dequeue') {
|
||||
if (this.bridgeNow) return;
|
||||
this.logger.info({opts}, `TaskEnqueue:notifyDequeueEvent: leaving ${this.queueName} because someone wants me`);
|
||||
assert(opts.dequeueSipAddress && opts.epUuid && opts.notifyUrl);
|
||||
this.emitter.emit('dequeue', opts);
|
||||
|
||||
try {
|
||||
const {lengthOfList} = cs.srf.locals.dbHelpers;
|
||||
const members = await lengthOfList(this.queueName);
|
||||
this.dequeued = true;
|
||||
cs.performQueueWebhook({
|
||||
event: 'leave',
|
||||
queue: this.data.name,
|
||||
length: Math.max(members - 1, 0),
|
||||
leaveReason: 'dequeued',
|
||||
leaveTime: Date.now(),
|
||||
dequeuer: opts.dequeuer
|
||||
});
|
||||
} catch (err) {}
|
||||
}
|
||||
else if (opts.event === 'hangup') {
|
||||
this.emitter.emit('hangup');
|
||||
@@ -259,6 +302,8 @@ class TaskEnqueue extends Task {
|
||||
|
||||
async _playHook(cs, dlg, hook, allowed = [TaskName.Play, TaskName.Say, TaskName.Pause, TaskName.Leave]) {
|
||||
const {lengthOfList, getListPosition} = cs.srf.locals.dbHelpers;
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
|
||||
assert(!this._playSession);
|
||||
if (this.killed) return [];
|
||||
@@ -274,10 +319,10 @@ class TaskEnqueue extends Task {
|
||||
} catch (err) {
|
||||
this.logger.error({err}, `TaskEnqueue:_playHook error retrieving list info for queue ${this.queueName}`);
|
||||
}
|
||||
const json = await cs.application.requestor.request(hook, params);
|
||||
const json = await cs.application.requestor.request('verb:hook', hook, params, httpHeaders);
|
||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
|
||||
const allowedTasks = tasks.filter((t) => allowed.includes(t.verb));
|
||||
const allowedTasks = tasks.filter((t) => allowed.includes(t.name));
|
||||
if (tasks.length !== allowedTasks.length) {
|
||||
this.logger.debug({tasks, allowedTasks}, 'unsupported task');
|
||||
throw new Error(`unsupported verb in enqueue waitHook: only ${JSON.stringify(allowed)}`);
|
||||
@@ -286,16 +331,15 @@ class TaskEnqueue extends Task {
|
||||
|
||||
// check for 'leave' verb and only execute tasks up till then
|
||||
const tasksToRun = [];
|
||||
let leave = false;
|
||||
for (const o of tasks) {
|
||||
if (o.verb === TaskName.Leave) {
|
||||
leave = true;
|
||||
if (o.name === TaskName.Leave) {
|
||||
this._leave = true;
|
||||
this.logger.info('waitHook returned a leave task');
|
||||
break;
|
||||
}
|
||||
tasksToRun.push(o);
|
||||
}
|
||||
|
||||
const cloneTasks = [...tasksToRun];
|
||||
if (this.killed) return [];
|
||||
else if (tasksToRun.length > 0) {
|
||||
this._playSession = new ConfirmCallSession({
|
||||
@@ -304,16 +348,18 @@ class TaskEnqueue extends Task {
|
||||
dlg,
|
||||
ep: cs.ep,
|
||||
callInfo: cs.callInfo,
|
||||
tasksToRun
|
||||
accountInfo: cs.accountInfo,
|
||||
tasks: tasksToRun,
|
||||
rootSpan: cs.rootSpan
|
||||
});
|
||||
await this._playSession.exec();
|
||||
this._playSession = null;
|
||||
}
|
||||
if (leave) {
|
||||
if (this._leave) {
|
||||
this.state = QueueResults.Leave;
|
||||
this.kill(cs);
|
||||
}
|
||||
return tasksToRun;
|
||||
return cloneTasks;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,63 +3,140 @@ const {
|
||||
TaskName,
|
||||
TaskPreconditions,
|
||||
GoogleTranscriptionEvents,
|
||||
AwsTranscriptionEvents
|
||||
AwsTranscriptionEvents,
|
||||
AzureTranscriptionEvents
|
||||
} = require('../utils/constants');
|
||||
|
||||
const makeTask = require('./make_task');
|
||||
const assert = require('assert');
|
||||
//const GATHER_STABILITY_THRESHOLD = Number(process.env.JAMBONZ_GATHER_STABILITY_THRESHOLD || 0.7);
|
||||
|
||||
class TaskGather extends Task {
|
||||
constructor(logger, opts) {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
[
|
||||
'finishOnKey', 'hints', 'input', 'numDigits',
|
||||
'partialResultHook',
|
||||
'finishOnKey', 'hints', 'input', 'numDigits', 'minDigits', 'maxDigits',
|
||||
'interDigitTimeout', 'partialResultHook', 'bargein', 'dtmfBargein',
|
||||
'speechTimeout', 'timeout', 'say', 'play'
|
||||
].forEach((k) => this[k] = this.data[k]);
|
||||
|
||||
this.timeout = (this.timeout || 5) * 1000;
|
||||
this.interim = this.partialResultCallback;
|
||||
/* when collecting dtmf, bargein on dtmf is true unless explicitly set to false */
|
||||
if (this.dtmfBargein !== false && this.input.includes('digits')) this.dtmfBargein = true;
|
||||
|
||||
/* timeout of zero means no timeout */
|
||||
this.timeout = this.timeout === 0 ? 0 : (this.timeout || 15) * 1000;
|
||||
this.interim = this.partialResultHook || this.bargein;
|
||||
this.listenDuringPrompt = this.data.listenDuringPrompt === false ? false : true;
|
||||
this.minBargeinWordCount = this.data.minBargeinWordCount || 0;
|
||||
if (this.data.recognizer) {
|
||||
const recognizer = this.data.recognizer;
|
||||
this.vendor = recognizer.vendor;
|
||||
this.language = recognizer.language;
|
||||
this.hints = recognizer.hints || [];
|
||||
this.hintsBoost = recognizer.hintsBoost;
|
||||
this.profanityFilter = recognizer.profanityFilter;
|
||||
this.punctuation = !!recognizer.punctuation;
|
||||
this.enhancedModel = !!recognizer.enhancedModel;
|
||||
this.model = recognizer.model || 'command_and_search';
|
||||
this.words = !!recognizer.words;
|
||||
this.singleUtterance = recognizer.singleUtterance || true;
|
||||
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 || [];
|
||||
|
||||
/* continuous ASR (i.e. compile transcripts until a special timeout or dtmf key) */
|
||||
this.asrTimeout = typeof recognizer.asrTimeout === 'number' ? recognizer.asrTimeout * 1000 : 0;
|
||||
if (this.asrTimeout > 0) this.asrDtmfTerminationDigit = recognizer.asrDtmfTerminationDigit;
|
||||
this.isContinuousAsr = this.asrTimeout > 0;
|
||||
|
||||
/* vad: if provided, we dont connect to recognizer until voice activity is detected */
|
||||
const {enable, voiceMs = 0, mode = -1} = recognizer.vad || {};
|
||||
this.vad = {enable, voiceMs, mode};
|
||||
|
||||
/* aws options */
|
||||
this.vocabularyName = recognizer.vocabularyName;
|
||||
this.vocabularyFilterName = recognizer.vocabularyFilterName;
|
||||
this.filterMethod = recognizer.filterMethod;
|
||||
|
||||
/* microsoft options */
|
||||
this.outputFormat = recognizer.outputFormat || 'simple';
|
||||
this.profanityOption = recognizer.profanityOption || 'raw';
|
||||
this.requestSnr = recognizer.requestSnr || false;
|
||||
this.initialSpeechTimeoutMs = recognizer.initialSpeechTimeoutMs || 0;
|
||||
this.azureServiceEndpoint = recognizer.azureServiceEndpoint;
|
||||
}
|
||||
else {
|
||||
this.hints = [];
|
||||
this.altLanguages = [];
|
||||
}
|
||||
|
||||
this.digitBuffer = '';
|
||||
this._earlyMedia = this.data.earlyMedia === true;
|
||||
|
||||
if (this.say) this.sayTask = makeTask(this.logger, {say: this.say}, this);
|
||||
if (this.play) this.playTask = makeTask(this.logger, {play: this.play}, this);
|
||||
if (this.say) {
|
||||
this.sayTask = makeTask(this.logger, {say: this.say}, this);
|
||||
}
|
||||
if (this.play) {
|
||||
this.playTask = makeTask(this.logger, {play: this.play}, this);
|
||||
}
|
||||
if (!this.sayTask && !this.playTask) this.listenDuringPrompt = false;
|
||||
|
||||
/* buffer speech for continueous asr */
|
||||
this._bufferedTranscripts = [];
|
||||
|
||||
this.parentTask = parentTask;
|
||||
}
|
||||
|
||||
get name() { return TaskName.Gather; }
|
||||
|
||||
get needsStt() { return this.input.includes('speech'); }
|
||||
|
||||
get earlyMedia() {
|
||||
return (this.sayTask && this.sayTask.earlyMedia) ||
|
||||
(this.playTask && this.playTask.earlyMedia);
|
||||
}
|
||||
|
||||
get summary() {
|
||||
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);
|
||||
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
|
||||
|
||||
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;
|
||||
if ('default' === this.vendor || !this.vendor) this.vendor = cs.speechRecognizerVendor;
|
||||
if ('default' === this.language || !this.language) this.language = cs.speechRecognizerLanguage;
|
||||
this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt');
|
||||
if (!this.sttCredentials) {
|
||||
if (this.needsStt && !this.sttCredentials) {
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
this.logger.info(`TaskGather:exec - ERROR stt using ${this.vendor} requested but not creds supplied`);
|
||||
this.logger.info(`TaskGather:exec - ERROR stt using ${this.vendor} requested but creds not supplied`);
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.STT_NOT_PROVISIONED,
|
||||
@@ -69,30 +146,55 @@ class TaskGather extends Task {
|
||||
throw new Error(`no speech-to-text service credentials for ${this.vendor} have been configured`);
|
||||
}
|
||||
|
||||
const startListening = (cs, ep) => {
|
||||
this._startTimer();
|
||||
if (this.isContinuousAsr && 0 === this.timeout) this._startAsrTimer();
|
||||
if (this.input.includes('speech') && !this.listenDuringPrompt) {
|
||||
this._initSpeech(cs, ep)
|
||||
.then(() => {
|
||||
this._startTranscribing(ep);
|
||||
return updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (this.sayTask) {
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.sayTask.summary}`);
|
||||
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) => {
|
||||
if (!this.killed) this._startTimer();
|
||||
span.end();
|
||||
if (err) this.logger.error({err}, 'Gather:exec Error playing tts');
|
||||
this.logger.debug('Gather: nested say task completed');
|
||||
if (!this.killed) startListening(cs, ep);
|
||||
});
|
||||
}
|
||||
else if (this.playTask) {
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.playTask.summary}`);
|
||||
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) => {
|
||||
if (!this.killed) this._startTimer();
|
||||
span.end();
|
||||
if (err) this.logger.error({err}, 'Gather:exec Error playing url');
|
||||
this.logger.debug('Gather: nested play task completed');
|
||||
if (!this.killed) startListening(cs, ep);
|
||||
});
|
||||
}
|
||||
else this._startTimer();
|
||||
else startListening(cs, ep);
|
||||
|
||||
if (this.input.includes('speech')) {
|
||||
if (this.input.includes('speech') && this.listenDuringPrompt) {
|
||||
await this._initSpeech(cs, ep);
|
||||
this._startTranscribing(ep);
|
||||
updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid)
|
||||
.catch(() => {/*already logged error */});
|
||||
}
|
||||
|
||||
if (this.input.includes('digits')) {
|
||||
ep.on('dtmf', this._onDtmf.bind(this, ep));
|
||||
if (this.input.includes('digits') || this.dtmfBargein || this.asrDtmfTerminationDigit) {
|
||||
ep.on('dtmf', this._onDtmf.bind(this, cs, ep));
|
||||
}
|
||||
|
||||
await this.awaitTaskDone();
|
||||
@@ -101,70 +203,163 @@ class TaskGather extends Task {
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
kill(cs) {
|
||||
super.kill(cs);
|
||||
this._killAudio();
|
||||
this._killAudio(cs);
|
||||
this.ep.removeAllListeners('dtmf');
|
||||
clearTimeout(this.interDigitTimer);
|
||||
this.playTask?.span.end();
|
||||
this.sayTask?.span.end();
|
||||
this._resolve('killed');
|
||||
}
|
||||
|
||||
_onDtmf(ep, evt) {
|
||||
updateTimeout(timeout) {
|
||||
this.logger.info(`TaskGather:updateTimeout - updating timeout to ${timeout}`);
|
||||
this.timeout = timeout;
|
||||
this._startTimer();
|
||||
}
|
||||
|
||||
_onDtmf(cs, ep, evt) {
|
||||
this.logger.debug(evt, 'TaskGather:_onDtmf');
|
||||
if (evt.dtmf === this.finishOnKey) this._resolve('dtmf-terminator-key');
|
||||
else {
|
||||
this.digitBuffer += evt.dtmf;
|
||||
if (this.digitBuffer.length === this.numDigits) this._resolve('dtmf-num-digits');
|
||||
clearTimeout(this.interDigitTimer);
|
||||
let resolved = false;
|
||||
if (this.dtmfBargein) {
|
||||
this._killAudio(cs);
|
||||
this.emit('dtmf', evt);
|
||||
}
|
||||
if (evt.dtmf === this.finishOnKey && this.input.includes('digits')) {
|
||||
resolved = true;
|
||||
this._resolve('dtmf-terminator-key');
|
||||
}
|
||||
else if (this.input.includes('digits')) {
|
||||
this.digitBuffer += evt.dtmf;
|
||||
const len = this.digitBuffer.length;
|
||||
if (len === this.numDigits || len === this.maxDigits) {
|
||||
resolved = true;
|
||||
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._resolve(this._bufferedTranscripts.length > 0 ? 'speech' : 'timeout');
|
||||
}
|
||||
if (!resolved && this.interDigitTimeout > 0 && this.digitBuffer.length >= this.minDigits) {
|
||||
/* start interDigitTimer */
|
||||
const ms = this.interDigitTimeout * 1000;
|
||||
this.logger.debug(`starting interdigit timer of ${ms}`);
|
||||
this.interDigitTimer = setTimeout(() => this._resolve('dtmf-interdigit-timeout'), ms);
|
||||
}
|
||||
this._killAudio();
|
||||
}
|
||||
|
||||
async _initSpeech(cs, ep) {
|
||||
const opts = {};
|
||||
|
||||
if (this.vad?.enable) {
|
||||
opts.START_RECOGNIZING_ON_VAD = 1;
|
||||
if (this.vad.voiceMs) opts.RECOGNIZER_VAD_VOICE_MS = this.vad.voiceMs;
|
||||
else opts.RECOGNIZER_VAD_VOICE_MS = 125;
|
||||
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'
|
||||
[
|
||||
['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'],
|
||||
['singleUtterance', 'GOOGLE_SPEECH_SINGLE_UTTERANCE'],
|
||||
['diarization', 'GOOGLE_SPEECH_PROFANITY_FILTER']
|
||||
].forEach((arr) => {
|
||||
if (this[arr[0]]) opts[arr[1]] = true;
|
||||
});
|
||||
if (this.hints && this.hints.length > 1) opts.GOOGLE_SPEECH_HINTS = this.hints.join(',');
|
||||
if (this.altLanguages && this.altLanguages.length > 1) {
|
||||
opts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
|
||||
if (this.hints.length > 1) {
|
||||
opts.GOOGLE_SPEECH_HINTS = this.hints.join(',');
|
||||
if (typeof this.hintsBoost === 'number') {
|
||||
opts.GOOGLE_SPEECH_HINTS_BOOST = this.hintsBoost;
|
||||
}
|
||||
}
|
||||
if (this.profanityFilter === true) {
|
||||
Object.assign(opts, {'GOOGLE_SPEECH_PROFANITY_FILTER': true});
|
||||
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;
|
||||
}
|
||||
opts.GOOGLE_SPEECH_MODEL = this.model;
|
||||
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;
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance, this._onEndOfUtterance.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep));
|
||||
}
|
||||
else {
|
||||
else if (['aws', 'polly'].includes(this.vendor)) {
|
||||
if (this.vocabularyName) opts.AWS_VOCABULARY_NAME = this.vocabularyName;
|
||||
if (this.vocabularyFilterName) {
|
||||
opts.AWS_VOCABULARY_NAME = this.vocabularyFilterName;
|
||||
opts.AWS_VOCABULARY_FILTER_METHOD = this.filterMethod || 'mask';
|
||||
}
|
||||
Object.assign(opts, {
|
||||
AWS_ACCESS_KEY_ID: this.sttCredentials.accessKeyId,
|
||||
AWS_SECRET_ACCESS_KEY: this.sttCredentials.secretAccessKey,
|
||||
AWS_REGION: this.sttCredentials.region
|
||||
});
|
||||
if (this.sttCredentials) {
|
||||
Object.assign(opts, {
|
||||
AWS_ACCESS_KEY_ID: this.sttCredentials.accessKeyId,
|
||||
AWS_SECRET_ACCESS_KEY: this.sttCredentials.secretAccessKey,
|
||||
AWS_REGION: this.sttCredentials.region
|
||||
});
|
||||
}
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep));
|
||||
}
|
||||
else if ('microsoft' === this.vendor) {
|
||||
if (this.sttCredentials) {
|
||||
Object.assign(opts, {
|
||||
'AZURE_SUBSCRIPTION_KEY': this.sttCredentials.api_key,
|
||||
'AZURE_REGION': this.sttCredentials.region
|
||||
});
|
||||
}
|
||||
if (this.hints && this.hints.length > 1) {
|
||||
opts.AZURE_SPEECH_HINTS = this.hints.map((h) => h.trim()).join(',');
|
||||
}
|
||||
if (this.altLanguages && this.altLanguages.length > 0) {
|
||||
opts.AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
|
||||
}
|
||||
if (this.requestSnr) opts.AZURE_REQUEST_SNR = 1;
|
||||
if (this.profanityOption && this.profanityOption !== 'raw') opts.AZURE_PROFANITY_OPTION = this.profanityOption;
|
||||
if (this.azureServiceEndpoint) opts.AZURE_SERVICE_ENDPOINT = this.azureServiceEndpoint;
|
||||
if (this.initialSpeechTimeoutMs > 0) opts.AZURE_INITIAL_SPEECH_TIMEOUT_MS = this.initialSpeechTimeoutMs;
|
||||
else if (this.timeout === 0) opts.AZURE_INITIAL_SPEECH_TIMEOUT_MS = 120000; // lengthy
|
||||
opts.AZURE_USE_OUTPUT_FORMAT_DETAILED = 1;
|
||||
|
||||
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected, this._onNoSpeechDetected.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(AzureTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep));
|
||||
}
|
||||
this.logger.debug({vars: opts}, 'setting freeswitch vars');
|
||||
await ep.set(opts)
|
||||
.catch((err) => this.logger.info(err, 'Error setting channel variables'));
|
||||
|
||||
}
|
||||
|
||||
_startTranscribing(ep) {
|
||||
this.logger.debug({
|
||||
vendor: this.vendor,
|
||||
locale: this.language,
|
||||
interim: this.interim
|
||||
}, 'Gather:_startTranscribing');
|
||||
ep.startTranscription({
|
||||
vendor: this.vendor,
|
||||
locale: this.language,
|
||||
interim: this.partialResultCallback ? true : false,
|
||||
interim: this.interim,
|
||||
}).catch((err) => {
|
||||
const {writeAlerts, AlertType} = this.cs.srf.locals;
|
||||
this.logger.error(err, 'TaskGather:_startTranscribing error');
|
||||
@@ -178,9 +373,12 @@ class TaskGather extends Task {
|
||||
}
|
||||
|
||||
_startTimer() {
|
||||
assert(!this._timeoutTimer);
|
||||
this.logger.debug(`Gather:_startTimer: timeout ${this.timeout}`);
|
||||
this._timeoutTimer = setTimeout(() => this._resolve('timeout'), this.timeout);
|
||||
if (0 === this.timeout) return;
|
||||
this._clearTimer();
|
||||
this._timeoutTimer = setTimeout(() => {
|
||||
if (this.isContinuousAsr) this._startAsrTimer();
|
||||
else this._resolve(this.digitBuffer.length >= this.minDigits ? 'dtmf-num-digits' : 'timeout');
|
||||
}, this.timeout);
|
||||
}
|
||||
|
||||
_clearTimer() {
|
||||
@@ -190,53 +388,189 @@ class TaskGather extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
_killAudio() {
|
||||
_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;
|
||||
}
|
||||
|
||||
_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) {
|
||||
this.sayTask.removeAllListeners('playDone');
|
||||
this.sayTask.kill();
|
||||
this.sayTask.kill(cs);
|
||||
this.sayTask = null;
|
||||
}
|
||||
if (this.playTask && !this.playTask.killed) {
|
||||
this.playTask.removeAllListeners('playDone');
|
||||
this.playTask.kill();
|
||||
this.playTask.kill(cs);
|
||||
this.playTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
_onTranscription(cs, ep, evt) {
|
||||
if ('aws' === this.vendor && Array.isArray(evt) && evt.length > 0) evt = evt[0];
|
||||
this.logger.debug(evt, 'TaskGather:_onTranscription');
|
||||
const final = evt.is_final;
|
||||
if (final) {
|
||||
this._resolve('speech', evt);
|
||||
if ('microsoft' === this.vendor) {
|
||||
const final = evt.RecognitionStatus === 'Success';
|
||||
if (final) {
|
||||
// don't sort based on confidence: https://github.com/Azure-Samples/cognitive-services-speech-sdk/issues/1463
|
||||
//const nbest = evt.NBest.sort((a, b) => b.Confidence - a.Confidence);
|
||||
const nbest = evt.NBest;
|
||||
const language_code = evt.PrimaryLanguage?.Language || this.language;
|
||||
evt = {
|
||||
is_final: true,
|
||||
language_code,
|
||||
alternatives: [
|
||||
{
|
||||
confidence: nbest[0].Confidence,
|
||||
transcript: nbest[0].Display
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
else {
|
||||
evt = {
|
||||
is_final: false,
|
||||
alternatives: [
|
||||
{
|
||||
transcript: evt.Text
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
else if (this.partialResultHook) {
|
||||
this.cs.requestor.request(this.partialResultHook, Object.assign({speech: evt}, this.cs.callInfo))
|
||||
.catch((err) => this.logger.info(err, 'GatherTask:_onTranscription error'));
|
||||
if (evt.is_final) {
|
||||
if (evt.alternatives[0].transcript === '' && !this.callSession.callGone && !this.killed) {
|
||||
this.logger.info({evt}, 'TaskGather:_onTranscription - got empty transcript, listen again');
|
||||
return this._startTranscribing(ep);
|
||||
}
|
||||
if (this.isContinuousAsr) {
|
||||
/* append the transcript and start listening again for asrTimeout */
|
||||
this.logger.info({evt}, 'TaskGather:_onTranscription - got transcript during continous asr');
|
||||
this._bufferedTranscripts.push(evt);
|
||||
this._clearTimer();
|
||||
this._startAsrTimer();
|
||||
return this._startTranscribing(ep);
|
||||
}
|
||||
else this._resolve('speech', evt);
|
||||
}
|
||||
else {
|
||||
/* google has a measure of stability:
|
||||
https://cloud.google.com/speech-to-text/docs/basics#streaming_responses
|
||||
others do not.
|
||||
*/
|
||||
//const isStableEnough = typeof evt.stability === 'undefined' || evt.stability > GATHER_STABILITY_THRESHOLD;
|
||||
|
||||
if (this.bargein && /* isStableEnough && */
|
||||
evt.alternatives[0].transcript.split(' ').length >= this.minBargeinWordCount) {
|
||||
if (!this.playComplete) {
|
||||
this.logger.debug({transcript: evt.alternatives[0].transcript}, 'killing audio due to speech');
|
||||
this.emit('vad');
|
||||
}
|
||||
this._killAudio(cs);
|
||||
}
|
||||
if (this.partialResultHook) {
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
this.cs.requestor.request(this.partialResultHook, Object.assign({speech: evt},
|
||||
this.cs.callInfo, httpHeaders));
|
||||
}
|
||||
}
|
||||
}
|
||||
_onEndOfUtterance(cs, ep) {
|
||||
this.logger.info('TaskGather:_onEndOfUtterance');
|
||||
this.logger.debug('TaskGather:_onEndOfUtterance');
|
||||
if (this.bargein && this.minBargeinWordCount === 0) {
|
||||
this._killAudio(cs);
|
||||
}
|
||||
|
||||
if (!this.resolved && !this.killed) {
|
||||
this._startTranscribing(ep);
|
||||
}
|
||||
}
|
||||
|
||||
async _resolve(reason, evt) {
|
||||
if (this.resolved) return;
|
||||
this.resolved = true;
|
||||
this.logger.debug(`TaskGather:resolve with reason ${reason}`);
|
||||
_onVadDetected(cs, ep) {
|
||||
if (this.bargein && this.minBargeinWordCount === 0) {
|
||||
this.logger.debug('TaskGather:_onVadDetected');
|
||||
this._killAudio(cs);
|
||||
this.emit('vad');
|
||||
}
|
||||
}
|
||||
|
||||
_onNoSpeechDetected(cs, ep) {
|
||||
if (!this.callSession.callGone && !this.killed) {
|
||||
this.logger.debug('TaskGather:_onNoSpeechDetected - listen again');
|
||||
return this._startTranscribing(ep);
|
||||
}
|
||||
}
|
||||
|
||||
async _resolve(reason, evt) {
|
||||
this.logger.debug(`TaskGather:resolve with reason ${reason}`);
|
||||
if (this.resolved) return;
|
||||
|
||||
this.resolved = true;
|
||||
clearTimeout(this.interDigitTimer);
|
||||
this._clearTimer();
|
||||
|
||||
if (this.isContinuousAsr && reason.startsWith('speech')) {
|
||||
evt = {
|
||||
is_final: true,
|
||||
transcripts: this._bufferedTranscripts
|
||||
};
|
||||
this.logger.debug({evt}, 'TaskGather:resolve continuous asr');
|
||||
}
|
||||
|
||||
this.span.setAttributes({'stt.resolve': reason, 'stt.result': JSON.stringify(evt)});
|
||||
if (this.ep && this.ep.connected) {
|
||||
this.ep.stopTranscription({vendor: this.vendor})
|
||||
.catch((err) => this.logger.error({err}, 'Error stopping transcription'));
|
||||
}
|
||||
|
||||
this._clearTimer();
|
||||
if (reason.startsWith('dtmf')) {
|
||||
await this.performAction({digits: this.digitBuffer});
|
||||
}
|
||||
else if (reason.startsWith('speech')) {
|
||||
await this.performAction({speech: evt});
|
||||
if (this.callSession && this.callSession.callGone) {
|
||||
this.logger.debug('TaskGather:_resolve - call is gone, not invoking web callback');
|
||||
this.notifyTaskDone();
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,12 +182,13 @@ class Lex extends Task {
|
||||
const type = messages[0].type;
|
||||
if (['PlainText', 'SSML'].includes(type) && msg) {
|
||||
const {srf} = cs;
|
||||
const {stats} = srf.locals;
|
||||
const {synthAudio} = srf.locals.dbHelpers;
|
||||
|
||||
try {
|
||||
this.logger.debug(`tts with ${this.vendor} ${this.voice}`);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const {filePath, servedFromCache} = await synthAudio({
|
||||
const {filePath, servedFromCache} = await synthAudio(stats, {
|
||||
text: msg,
|
||||
vendor: this.vendor,
|
||||
language: this.language,
|
||||
@@ -288,7 +289,9 @@ class Lex extends Task {
|
||||
}
|
||||
|
||||
async _performHook(cs, hook, results) {
|
||||
const json = await this.cs.requestor.request(hook, results);
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
const json = await this.cs.requestor.request('verb:hook', hook, results, httpHeaders);
|
||||
if (json && Array.isArray(json)) {
|
||||
const makeTask = require('./make_task');
|
||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
|
||||
@@ -38,7 +38,12 @@ class TaskListen extends Task {
|
||||
if (this.playBeep) await this._playBeep(ep);
|
||||
if (this.transcribeTask) {
|
||||
this.logger.debug('TaskListen:exec - starting nested transcribe task');
|
||||
this.transcribeTask.exec(cs, ep);
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.transcribeTask.summary}`);
|
||||
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.awaitTaskDone();
|
||||
@@ -64,6 +69,7 @@ class TaskListen extends Task {
|
||||
this.results.dialCallDuration = duration;
|
||||
}
|
||||
if (this.transcribeTask) await this.transcribeTask.kill(cs);
|
||||
this.ep && this._removeListeners(this.ep);
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
@@ -122,6 +128,11 @@ class TaskListen extends Task {
|
||||
if (this.finishOnKey || this.passDtmf) {
|
||||
ep.on('dtmf', this._dtmfHandler);
|
||||
}
|
||||
|
||||
/* support bi-directional audio */
|
||||
ep.addCustomEventListener(ListenEvents.PlayAudio, this._onPlayAudio.bind(this, ep));
|
||||
ep.addCustomEventListener(ListenEvents.KillAudio, this._onKillAudio.bind(this, ep));
|
||||
ep.addCustomEventListener(ListenEvents.Disconnect, this._onDisconnect.bind(this, ep));
|
||||
}
|
||||
|
||||
_removeListeners(ep) {
|
||||
@@ -131,6 +142,10 @@ class TaskListen extends Task {
|
||||
if (this.finishOnKey || this.passDtmf) {
|
||||
ep.removeListener('dtmf', this._dtmfHandler);
|
||||
}
|
||||
ep.removeCustomEventListener(ListenEvents.PlayAudio);
|
||||
ep.removeCustomEventListener(ListenEvents.KillAudio);
|
||||
ep.removeCustomEventListener(ListenEvents.Disconnect);
|
||||
|
||||
}
|
||||
|
||||
_onDtmf(evt) {
|
||||
@@ -154,6 +169,29 @@ class TaskListen extends Task {
|
||||
this.logger.info(evt, 'TaskListen:_onConnectFailure');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
async _onPlayAudio(ep, evt) {
|
||||
this.logger.info(`received play_audio event: ${JSON.stringify(evt)}`);
|
||||
try {
|
||||
const results = await ep.play(evt.file);
|
||||
this.logger.debug(`Finished playing file, result: ${JSON.stringify(results)}`);
|
||||
ep.forkAudioSendText({type: 'playDone', data: Object.assign({id: evt.id}, results)});
|
||||
}
|
||||
catch (err) {
|
||||
this.logger.error({err}, 'Error playing file');
|
||||
}
|
||||
}
|
||||
|
||||
_onKillAudio(ep) {
|
||||
this.logger.info('received kill_audio event');
|
||||
ep.api('uuid_break', ep.uuid);
|
||||
}
|
||||
|
||||
_onDisconnect(ep, cs) {
|
||||
this.logger.debug('_onDisconnect: TaskListen terminating task');
|
||||
this.kill(cs);
|
||||
}
|
||||
|
||||
_onError(ep, evt) {
|
||||
this.logger.info(evt, 'TaskListen:_onError');
|
||||
this.notifyTaskDone();
|
||||
|
||||
@@ -17,8 +17,16 @@ function makeTask(logger, obj, parent) {
|
||||
case TaskName.SipDecline:
|
||||
const TaskSipDecline = require('./sip_decline');
|
||||
return new TaskSipDecline(logger, data, parent);
|
||||
case TaskName.SipRequest:
|
||||
const TaskSipRequest = require('./sip_request');
|
||||
return new TaskSipRequest(logger, data, parent);
|
||||
case TaskName.SipRefer:
|
||||
const TaskSipRefer = require('./sip_refer');
|
||||
return new TaskSipRefer(logger, data, parent);
|
||||
case TaskName.Config:
|
||||
const TaskConfig = require('./config');
|
||||
return new TaskConfig(logger, data, parent);
|
||||
case TaskName.Conference:
|
||||
logger.debug({data}, 'Conference verb');
|
||||
const TaskConference = require('./conference');
|
||||
return new TaskConference(logger, data, parent);
|
||||
case TaskName.Dial:
|
||||
@@ -48,6 +56,9 @@ function makeTask(logger, obj, parent) {
|
||||
case TaskName.Message:
|
||||
const TaskMessage = require('./message');
|
||||
return new TaskMessage(logger, data, parent);
|
||||
case TaskName.Rasa:
|
||||
const TaskRasa = require('./rasa');
|
||||
return new TaskRasa(logger, data, parent);
|
||||
case TaskName.Say:
|
||||
const TaskSay = require('./say');
|
||||
return new TaskSay(logger, data, parent);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
const bent = require('bent');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
class TaskMessage extends Task {
|
||||
constructor(logger, opts) {
|
||||
@@ -8,13 +9,11 @@ class TaskMessage extends Task {
|
||||
this.preconditions = TaskPreconditions.None;
|
||||
|
||||
this.payload = {
|
||||
message_sid: this.data.message_sid,
|
||||
provider: this.data.provider,
|
||||
message_sid: this.data.message_sid || uuidv4(),
|
||||
carrier: this.data.carrier,
|
||||
to: this.data.to,
|
||||
from: this.data.from,
|
||||
cc: this.data.cc,
|
||||
text: this.data.text,
|
||||
media: this.data.media
|
||||
text: this.data.text
|
||||
};
|
||||
|
||||
}
|
||||
@@ -28,20 +27,22 @@ class TaskMessage extends Task {
|
||||
const {srf, accountSid} = cs;
|
||||
const {res} = cs.callInfo;
|
||||
let payload = this.payload;
|
||||
const actionParams = {message_sid: this.payload.message_sid};
|
||||
|
||||
await super.exec(cs);
|
||||
try {
|
||||
const {getSBC, getSmpp, dbHelpers} = srf.locals;
|
||||
const {getSmpp, dbHelpers} = srf.locals;
|
||||
const {lookupSmppGateways} = dbHelpers;
|
||||
|
||||
this.logger.info(`looking up gateways for account_sid: ${accountSid}`);
|
||||
this.logger.debug(`looking up gateways for account_sid: ${accountSid}`);
|
||||
const r = await lookupSmppGateways(accountSid);
|
||||
let gw, url, relativeUrl;
|
||||
if (r.length > 0) {
|
||||
gw = r.find((o) => 1 === o.sg.outbound && (!this.payload.provider || o.vc.name === this.payload.provider));
|
||||
gw = r.find((o) => 1 === o.sg.outbound && (!this.payload.carrier || o.vc.name === this.payload.carrier));
|
||||
}
|
||||
if (gw) {
|
||||
this.logger.info({gw, accountSid}, 'Message:exec - using smpp to send message');
|
||||
url = getSmpp();
|
||||
url = process.env.K8S ? 'http://smpp' : getSmpp();
|
||||
relativeUrl = '/sms';
|
||||
payload = {
|
||||
...payload,
|
||||
@@ -50,37 +51,75 @@ class TaskMessage extends Task {
|
||||
};
|
||||
}
|
||||
else {
|
||||
this.logger.info({gw, accountSid, provider: this.payload.provider},
|
||||
//TMP: smpp only at the moment, need to add http back in
|
||||
/*
|
||||
this.logger.info({gw, accountSid, carrier: this.payload.carrier},
|
||||
'Message:exec - no smpp gateways found to send message');
|
||||
relativeUrl = 'v1/outboundSMS';
|
||||
const sbcAddress = getSBC();
|
||||
if (sbcAddress) url = `http://${sbcAddress}:3000/`;
|
||||
|
||||
//TMP: smpp only at the moment, need to add http back in
|
||||
return res.sendStatus(404);
|
||||
*/
|
||||
this.performAction({
|
||||
...actionParams,
|
||||
message_status: 'no carriers'
|
||||
}).catch((err) => {});
|
||||
if (res) res.sendStatus(404);
|
||||
return;
|
||||
}
|
||||
if (url) {
|
||||
const post = bent(url, 'POST', 'json', 201);
|
||||
const post = bent(url, 'POST', 'json', 201, 480);
|
||||
this.logger.info({payload, url}, 'Message:exec sending outbound SMS');
|
||||
const response = await post(relativeUrl, payload);
|
||||
this.logger.info({response}, 'Successfully sent SMS');
|
||||
if (cs.callInfo.res) {
|
||||
this.logger.info('Message:exec sending 200 OK response to HTTP POST from api server');
|
||||
res.status(200).json({
|
||||
sid: cs.callInfo.messageSid,
|
||||
providerResponse: response
|
||||
});
|
||||
const {smpp_err_code, carrier, message_id, message} = response;
|
||||
if (smpp_err_code) {
|
||||
this.logger.info({response}, 'SMPP error sending SMS');
|
||||
this.performAction({
|
||||
...actionParams,
|
||||
carrier,
|
||||
carrier_message_id: message_id,
|
||||
message_status: 'failure',
|
||||
message_failure_reason: message
|
||||
}).catch((err) => {});
|
||||
if (res) {
|
||||
res.status(480).json({
|
||||
...response,
|
||||
sid: cs.callInfo.messageSid
|
||||
});
|
||||
}
|
||||
}
|
||||
else {
|
||||
const {message_id, carrier} = response;
|
||||
this.logger.info({response}, 'Successfully sent SMS');
|
||||
this.performAction({
|
||||
...actionParams,
|
||||
carrier,
|
||||
carrier_message_id: message_id,
|
||||
message_status: 'success',
|
||||
}).catch((err) => {});
|
||||
if (res) {
|
||||
res.status(200).json({
|
||||
sid: cs.callInfo.messageSid,
|
||||
carrierResponse: response
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: action Hook
|
||||
}
|
||||
else {
|
||||
this.logger.info('Message:exec - unable to send SMS as there are no available SMS gateways');
|
||||
res.status(422).json({message: 'no configured SMS gateways'});
|
||||
this.logger.info('Message:exec - unable to send SMS as SMPP is not configured on the system');
|
||||
this.performAction({
|
||||
...actionParams,
|
||||
message_status: 'smpp configuration error'
|
||||
}).catch((err) => {});
|
||||
if (res) res.status(404).json({message: 'no configured SMS gateways'});
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'TaskMessage:exec - Error sending SMS');
|
||||
res.status(422).json({message: 'no configured SMS gateways'});
|
||||
this.logger.error(err, 'TaskMessage:exec - unexpected error sending SMS');
|
||||
this.performAction({
|
||||
...actionParams,
|
||||
message_status: 'system error',
|
||||
message_failure_reason: err.message
|
||||
});
|
||||
if (res) res.status(422).json({message: 'no configured SMS gateways'});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,20 @@ class TaskPlay extends Task {
|
||||
|
||||
get name() { return TaskName.Play; }
|
||||
|
||||
get summary() {
|
||||
return `${this.name}:{url=${this.url}}`;
|
||||
}
|
||||
|
||||
async exec(cs, ep) {
|
||||
await super.exec(cs);
|
||||
this.ep = ep;
|
||||
try {
|
||||
while (!this.killed && this.loop--) {
|
||||
await ep.play(this.url);
|
||||
while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep.connected) {
|
||||
if (cs.isInConference) {
|
||||
const {memberId, confName, confUuid} = cs;
|
||||
await this.playToConfMember(this.ep, memberId, confName, confUuid, this.url);
|
||||
}
|
||||
else await ep.play(this.url);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info(err, `TaskPlay:exec - error playing ${this.url}`);
|
||||
@@ -30,7 +38,13 @@ class TaskPlay extends Task {
|
||||
super.kill(cs);
|
||||
if (this.ep.connected && !this.playComplete) {
|
||||
this.logger.debug('TaskPlay:kill - killing audio');
|
||||
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
if (cs.isInConference) {
|
||||
const {memberId, confName} = cs;
|
||||
this.killPlayToConfMember(this.ep, memberId, confName);
|
||||
}
|
||||
else {
|
||||
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
170
lib/tasks/rasa.js
Normal file
170
lib/tasks/rasa.js
Normal file
@@ -0,0 +1,170 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
const makeTask = require('./make_task');
|
||||
const bent = require('bent');
|
||||
|
||||
class Rasa extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
this.prompt = this.data.prompt;
|
||||
this.eventHook = this.data?.eventHook;
|
||||
this.actionHook = this.data?.actionHook;
|
||||
this.post = bent('POST', 'json', 200);
|
||||
}
|
||||
|
||||
get name() { return TaskName.Rasa; }
|
||||
|
||||
get hasReportedFinalAction() {
|
||||
return this.reportedFinalAction || this.isReplacingApplication;
|
||||
}
|
||||
|
||||
async exec(cs, ep) {
|
||||
await super.exec(cs);
|
||||
|
||||
this.ep = ep;
|
||||
try {
|
||||
/* set event handlers */
|
||||
this.on('transcription', this._onTranscription.bind(this, cs, ep));
|
||||
this.on('timeout', this._onTimeout.bind(this, cs, ep));
|
||||
|
||||
/* start the first gather */
|
||||
this.gatherTask = this._makeGatherTask(this.prompt);
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.gatherTask.summary}`);
|
||||
this.gatherTask.span = span;
|
||||
this.gatherTask.ctx = ctx;
|
||||
this.gatherTask.exec(cs, ep, this)
|
||||
.then(() => span.end())
|
||||
.catch((err) => {
|
||||
span.end();
|
||||
this.logger.info({err}, 'Rasa gather task returned error');
|
||||
});
|
||||
|
||||
await this.awaitTaskDone();
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'Rasa error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
this.logger.debug('Rasa:kill');
|
||||
|
||||
if (!this.hasReportedFinalAction) {
|
||||
this.reportedFinalAction = true;
|
||||
this.performAction({rasaResult: 'caller hungup'})
|
||||
.catch((err) => this.logger.info({err}, 'rasa - error w/ action webook'));
|
||||
}
|
||||
|
||||
if (this.ep.connected) {
|
||||
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
}
|
||||
this.removeAllListeners();
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
_makeGatherTask(prompt) {
|
||||
let opts = {
|
||||
input: ['speech'],
|
||||
timeout: this.data.timeout || 10,
|
||||
recognizer: this.data.recognizer || {
|
||||
vendor: 'default',
|
||||
language: 'default'
|
||||
}
|
||||
};
|
||||
if (prompt) {
|
||||
const sayOpts = this.data.tts ?
|
||||
{text: prompt, synthesizer: this.data.tts} :
|
||||
{text: prompt};
|
||||
|
||||
opts = {
|
||||
...opts,
|
||||
say: sayOpts
|
||||
};
|
||||
}
|
||||
//this.logger.debug({opts}, 'constructing a nested gather object');
|
||||
const gather = makeTask(this.logger, {gather: opts}, this);
|
||||
return gather;
|
||||
}
|
||||
|
||||
async _onTranscription(cs, ep, evt) {
|
||||
//this.logger.debug({evt}, `Rasa: got transcription for callSid ${cs.callSid}`);
|
||||
const utterance = evt.alternatives[0].transcript;
|
||||
|
||||
if (this.eventHook) {
|
||||
this.performHook(cs, this.eventHook, {event: 'userMessage', message: utterance})
|
||||
.then((redirected) => {
|
||||
if (redirected) {
|
||||
this.logger.info('Rasa_onTranscription: event handler for user message redirected us to new webhook');
|
||||
this.reportedFinalAction = true;
|
||||
this.performAction({rasaResult: 'redirect'}, false);
|
||||
if (this.gatherTask) this.gatherTask.kill(cs);
|
||||
}
|
||||
return;
|
||||
})
|
||||
.catch(({err}) => {
|
||||
this.logger.info({err}, 'Rasa_onTranscription: error sending event hook');
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
sender: cs.callSid,
|
||||
message: utterance
|
||||
};
|
||||
this.logger.debug({payload}, 'Rasa:_onTranscription - sending payload to Rasa');
|
||||
const response = await this.post(this.data.url, payload);
|
||||
this.logger.debug({response}, 'Rasa:_onTranscription - got response from Rasa');
|
||||
const botUtterance = Array.isArray(response) ?
|
||||
response.reduce((prev, current) => {
|
||||
return current.text ? `${prev} ${current.text}` : '';
|
||||
}, '') :
|
||||
null;
|
||||
if (botUtterance) {
|
||||
this.logger.debug({botUtterance}, 'Rasa:_onTranscription: got user utterance');
|
||||
this.gatherTask = this._makeGatherTask(botUtterance);
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.gatherTask.summary}`);
|
||||
this.gatherTask.span = span;
|
||||
this.gatherTask.ctx = ctx;
|
||||
this.gatherTask.exec(cs, ep, this)
|
||||
.then(() => span.end())
|
||||
.catch((err) => {
|
||||
span.end();
|
||||
this.logger.info({err}, 'Rasa gather task returned error');
|
||||
});
|
||||
if (this.eventHook) {
|
||||
this.performHook(cs, this.eventHook, {event: 'botMessage', message: response})
|
||||
.then((redirected) => {
|
||||
if (redirected) {
|
||||
this.logger.info('Rasa_onTranscription: event handler for bot message redirected us to new webhook');
|
||||
this.reportedFinalAction = true;
|
||||
this.performAction({rasaResult: 'redirect'}, false);
|
||||
if (this.gatherTask) this.gatherTask.kill(cs);
|
||||
}
|
||||
return;
|
||||
})
|
||||
.catch(({err}) => {
|
||||
this.logger.info({err}, 'Rasa_onTranscription: error sending event hook');
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'Rasa_onTranscription: Error sending user utterance to Rasa - ending task');
|
||||
this.performAction({rasaResult: 'webhookError'});
|
||||
this.reportedFinalAction = true;
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
_onTimeout(cs, ep, evt) {
|
||||
this.logger.debug({evt}, 'Rasa: got timeout');
|
||||
if (!this.hasReportedFinalAction) this.performAction({rasaResult: 'timeout'});
|
||||
this.reportedFinalAction = true;
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
module.exports = Rasa;
|
||||
@@ -48,7 +48,23 @@ class TaskRestDial extends Task {
|
||||
cs.setDialog(dlg);
|
||||
|
||||
try {
|
||||
const tasks = await cs.requestor.request(this.call_hook, cs.callInfo);
|
||||
const b3 = this.getTracingPropagation();
|
||||
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)) {
|
||||
this.logger.debug({tasks: tasks}, `TaskRestDial: replacing application with ${tasks.length} tasks`);
|
||||
cs.replaceApplication(normalizeJambones(this.logger, tasks).map((tdata) => makeTask(this.logger, tdata)));
|
||||
|
||||
111
lib/tasks/say.js
111
lib/tasks/say.js
@@ -14,19 +14,35 @@ class TaskSay extends Task {
|
||||
|
||||
get name() { return TaskName.Say; }
|
||||
|
||||
get summary() {
|
||||
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);
|
||||
|
||||
const {srf} = cs;
|
||||
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, srf);
|
||||
const {writeAlerts, AlertType} = srf.locals;
|
||||
const {writeAlerts, AlertType, stats} = srf.locals;
|
||||
const {synthAudio} = srf.locals.dbHelpers;
|
||||
const vendor = this.synthesizer.vendor || cs.speechSynthesisVendor;
|
||||
const language = this.synthesizer.language || cs.speechSynthesisLanguage;
|
||||
const voice = this.synthesizer.voice || cs.speechSynthesisVoice;
|
||||
const vendor = this.synthesizer.vendor && this.synthesizer.vendor !== 'default' ?
|
||||
this.synthesizer.vendor :
|
||||
cs.speechSynthesisVendor;
|
||||
const language = this.synthesizer.language && this.synthesizer.language !== 'default' ?
|
||||
this.synthesizer.language :
|
||||
cs.speechSynthesisLanguage ;
|
||||
const voice = this.synthesizer.voice && this.synthesizer.voice !== 'default' ?
|
||||
this.synthesizer.voice :
|
||||
cs.speechSynthesisVoice;
|
||||
const engine = this.synthesizer.engine || 'standard';
|
||||
const salt = cs.callSid;
|
||||
const credentials = cs.getSpeechCredentials(vendor, 'tts');
|
||||
|
||||
this.logger.info({vendor, language, voice}, 'TaskSay:exec');
|
||||
this.ep = ep;
|
||||
try {
|
||||
if (!credentials) {
|
||||
@@ -35,44 +51,75 @@ class TaskSay extends Task {
|
||||
alert_type: AlertType.TTS_NOT_PROVISIONED,
|
||||
vendor
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for no tts'));
|
||||
this.notifyError(`No speech credentials have been provisioned for ${vendor}`);
|
||||
throw new Error('no provisioned speech credentials for TTS');
|
||||
}
|
||||
// synthesize all of the text elements
|
||||
let lastUpdated = false;
|
||||
const filepath = (await Promise.all(this.text.map(async(text) => {
|
||||
const {filePath, servedFromCache} = await synthAudio({
|
||||
text,
|
||||
vendor,
|
||||
language,
|
||||
voice,
|
||||
salt,
|
||||
credentials
|
||||
}).catch((err) => {
|
||||
this.logger.info(err, 'Error synthesizing tts');
|
||||
|
||||
/* produce an audio segment from the provided text */
|
||||
const generateAudio = async(text) => {
|
||||
if (this.killed) return;
|
||||
if (text.startsWith('silence_stream://')) return text;
|
||||
|
||||
/* otel: trace time for tts */
|
||||
const {span} = this.startChildSpan('tts-generation', {
|
||||
'tts.vendor': vendor,
|
||||
'tts.language': language,
|
||||
'tts.voice': voice
|
||||
});
|
||||
try {
|
||||
const {filePath, servedFromCache} = await synthAudio(stats, {
|
||||
text,
|
||||
vendor,
|
||||
language,
|
||||
voice,
|
||||
engine,
|
||||
salt,
|
||||
credentials
|
||||
});
|
||||
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();
|
||||
return filePath;
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Error synthesizing tts');
|
||||
span.end();
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.TTS_NOT_PROVISIONED,
|
||||
vendor,
|
||||
detail: err.message
|
||||
});
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for tts failure'));
|
||||
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 */});
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for tts failure'));
|
||||
this.notifyError(err.message || err);
|
||||
return;
|
||||
}
|
||||
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');
|
||||
|
||||
while (!this.killed && this.loop-- && this.ep.connected) {
|
||||
while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep?.connected) {
|
||||
let segment = 0;
|
||||
do {
|
||||
await ep.play(filepath[segment]);
|
||||
} while (!this.killed && ++segment < filepath.length);
|
||||
while (!this.killed && segment < filepath.length) {
|
||||
if (cs.isInConference) {
|
||||
const {memberId, confName, confUuid} = cs;
|
||||
await this.playToConfMember(this.ep, memberId, confName, confUuid, filepath[segment]);
|
||||
}
|
||||
else {
|
||||
this.logger.debug(`Say:exec sending command to play file ${filepath[segment]}`);
|
||||
await ep.play(filepath[segment]);
|
||||
this.logger.debug(`Say:exec completed play file ${filepath[segment]}`);
|
||||
}
|
||||
segment++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'TaskSay:exec error');
|
||||
@@ -84,7 +131,13 @@ class TaskSay extends Task {
|
||||
super.kill(cs);
|
||||
if (this.ep.connected) {
|
||||
this.logger.debug('TaskSay:kill - killing audio');
|
||||
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
if (cs.isInConference) {
|
||||
const {memberId, confName} = cs;
|
||||
this.killPlayToConfMember(this.ep, memberId, confName);
|
||||
}
|
||||
else {
|
||||
this.ep.api('uuid_break', this.ep.uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,11 @@ class TaskSipDecline extends Task {
|
||||
res.send(this.data.status, this.data.reason, {
|
||||
headers: this.headers
|
||||
});
|
||||
cs.emit('callStatusChange', {callStatus: CallStatus.Failed, sipStatus: this.data.status});
|
||||
cs.emit('callStatusChange', {
|
||||
callStatus: CallStatus.Failed,
|
||||
sipStatus: this.data.status,
|
||||
sipReason: this.data.reason
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
116
lib/tasks/sip_refer.js
Normal file
116
lib/tasks/sip_refer.js
Normal file
@@ -0,0 +1,116 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
const {parseUri} = require('drachtio-srf');
|
||||
|
||||
/**
|
||||
* sends a sip REFER to transfer the existing call
|
||||
*/
|
||||
class TaskSipRefer extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.StableCall;
|
||||
|
||||
this.referTo = this.data.referTo;
|
||||
this.referredBy = this.data.referredBy;
|
||||
this.headers = this.data.headers || {};
|
||||
this.eventHook = this.data.eventHook;
|
||||
}
|
||||
|
||||
get name() { return TaskName.SipRefer; }
|
||||
|
||||
async exec(cs) {
|
||||
super.exec(cs);
|
||||
const {dlg} = cs;
|
||||
const {referTo, referredBy} = this._normalizeReferHeaders(cs, dlg);
|
||||
|
||||
try {
|
||||
this.notifyHandler = this._handleNotify.bind(this, cs, dlg);
|
||||
dlg.on('notify', this.notifyHandler);
|
||||
/* otel: trace time for tts */
|
||||
this.referSpan = this.startSpan('send-refer', {
|
||||
'refer.refer_to': referTo,
|
||||
'refer.referred_by': referredBy
|
||||
});
|
||||
|
||||
const response = await dlg.request({
|
||||
method: 'REFER',
|
||||
headers: {
|
||||
...this.headers,
|
||||
'Refer-To': referTo,
|
||||
'Referred-By': referredBy
|
||||
}
|
||||
});
|
||||
this.referStatus = response.status;
|
||||
this.referSpan.setAttributes({'refer.status_code': response.status});
|
||||
this.logger.info(`TaskSipRefer:exec - received ${this.referStatus} to REFER`);
|
||||
|
||||
/* if we fail, fall through to next verb. If success, we should get BYE from far end */
|
||||
if (this.referStatus === 202) {
|
||||
await this.awaitTaskDone();
|
||||
}
|
||||
else {
|
||||
await this.performAction({refer_status: this.referStatus});
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'TaskSipRefer:exec - error sending REFER');
|
||||
}
|
||||
this.referSpan?.end();
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
const {dlg} = cs;
|
||||
dlg.off('notify', this.notifyHandler);
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
async _handleNotify(cs, dlg, req, res) {
|
||||
res.send(200);
|
||||
|
||||
const contentType = req.get('Content-Type');
|
||||
this.logger.debug({body: req.body}, `TaskSipRefer:_handleNotify got ${contentType}`);
|
||||
|
||||
if (contentType === 'message/sipfrag') {
|
||||
const arr = /SIP\/2\.0\s+(\d+)/.exec(req.body);
|
||||
if (arr) {
|
||||
const status = arr[1];
|
||||
this.logger.debug(`TaskSipRefer:_handleNotify: call got status ${status}`);
|
||||
if (this.eventHook) {
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
await cs.requestor.request('verb:hook', this.eventHook,
|
||||
{event: 'transfer-status', call_status: status}, httpHeaders);
|
||||
}
|
||||
if (status >= 200) {
|
||||
this.referSpan.setAttributes({'refer.finalNotify': status});
|
||||
await this.performAction({refer_status: 202, final_referred_call_status: status});
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_normalizeReferHeaders(cs, dlg) {
|
||||
let {referTo, referredBy} = this;
|
||||
|
||||
/* get IP address of the SBC to use as hostname if needed */
|
||||
const {host} = parseUri(dlg.remote.uri);
|
||||
|
||||
if (!referTo.startsWith('<') && !referTo.startsWith('sip') && !referTo.startsWith('"')) {
|
||||
/* they may have only provided a phone number/user */
|
||||
referTo = `sip:${referTo}@${host}`;
|
||||
}
|
||||
if (!referredBy) {
|
||||
/* default */
|
||||
referredBy = cs.req?.callingNumber || dlg.local.uri;
|
||||
this.logger.info({referredBy}, 'setting referredby');
|
||||
}
|
||||
if (!referredBy.startsWith('<') && !referredBy.startsWith('sip') && !referredBy.startsWith('"')) {
|
||||
/* they may have only provided a phone number/user */
|
||||
referredBy = `sip:${referredBy}@${host}`;
|
||||
}
|
||||
return {referTo, referredBy};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskSipRefer;
|
||||
48
lib/tasks/sip_request.js
Normal file
48
lib/tasks/sip_request.js
Normal file
@@ -0,0 +1,48 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -9,6 +9,56 @@
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"sip:request": {
|
||||
"properties": {
|
||||
"method": "string",
|
||||
"body": "string",
|
||||
"headers": "object",
|
||||
"actionHook": "object|string"
|
||||
},
|
||||
"required": [
|
||||
"method"
|
||||
]
|
||||
},
|
||||
"sip:refer": {
|
||||
"properties": {
|
||||
"referTo": "string",
|
||||
"referredBy": "string",
|
||||
"headers": "object",
|
||||
"actionHook": "object|string",
|
||||
"eventHook": "object|string"
|
||||
},
|
||||
"required": [
|
||||
"referTo"
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"properties": {
|
||||
"synthesizer": "#synthesizer",
|
||||
"recognizer": "#recognizer",
|
||||
"bargeIn": "#bargeIn",
|
||||
"record": "#recordOptions"
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"bargeIn": {
|
||||
"properties": {
|
||||
"enable": "boolean",
|
||||
"sticky": "boolean",
|
||||
"actionHook": "object|string",
|
||||
"input": "array",
|
||||
"finishOnKey": "string",
|
||||
"numDigits": "number",
|
||||
"minDigits": "number",
|
||||
"maxDigits": "number",
|
||||
"interDigitTimeout": "number",
|
||||
"dtmfBargein": "boolean",
|
||||
"minBargeinWordCount": "number"
|
||||
},
|
||||
"required": [
|
||||
"enable"
|
||||
]
|
||||
},
|
||||
"dequeue": {
|
||||
"properties": {
|
||||
"name": "string",
|
||||
@@ -46,7 +96,7 @@
|
||||
"play": {
|
||||
"properties": {
|
||||
"url": "string",
|
||||
"loop": "number",
|
||||
"loop": "number|string",
|
||||
"earlyMedia": "boolean"
|
||||
},
|
||||
"required": [
|
||||
@@ -56,7 +106,7 @@
|
||||
"say": {
|
||||
"properties": {
|
||||
"text": "string|array",
|
||||
"loop": "number",
|
||||
"loop": "number|string",
|
||||
"synthesizer": "#synthesizer",
|
||||
"earlyMedia": "boolean"
|
||||
},
|
||||
@@ -70,15 +120,21 @@
|
||||
"finishOnKey": "string",
|
||||
"input": "array",
|
||||
"numDigits": "number",
|
||||
"minDigits": "number",
|
||||
"maxDigits": "number",
|
||||
"interDigitTimeout": "number",
|
||||
"partialResultHook": "object|string",
|
||||
"speechTimeout": "number",
|
||||
"listenDuringPrompt": "boolean",
|
||||
"dtmfBargein": "boolean",
|
||||
"bargein": "boolean",
|
||||
"minBargeinWordCount": "number",
|
||||
"timeout": "number",
|
||||
"recognizer": "#recognizer",
|
||||
"play": "#play",
|
||||
"say": "#say"
|
||||
},
|
||||
"required": [
|
||||
"actionHook"
|
||||
]
|
||||
},
|
||||
"conference": {
|
||||
@@ -88,11 +144,13 @@
|
||||
"startConferenceOnEnter": "boolean",
|
||||
"endConferenceOnExit": "boolean",
|
||||
"maxParticipants": "number",
|
||||
"joinMuted": "boolean",
|
||||
"actionHook": "object|string",
|
||||
"waitHook": "object|string",
|
||||
"statusEvents": "array",
|
||||
"statusHook": "object|string",
|
||||
"enterHook": "object|string"
|
||||
"enterHook": "object|string",
|
||||
"record": "#record"
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
@@ -104,6 +162,7 @@
|
||||
"answerOnBridge": "boolean",
|
||||
"callerId": "string",
|
||||
"confirmHook": "object|string",
|
||||
"referHook": "object|string",
|
||||
"dialMusic": "string",
|
||||
"dtmfCapture": "object",
|
||||
"dtmfHook": "object|string",
|
||||
@@ -124,6 +183,10 @@
|
||||
"credentials": "object|string",
|
||||
"project": "string",
|
||||
"environment": "string",
|
||||
"region": {
|
||||
"type": "string",
|
||||
"enum": ["europe-west1", "europe-west2", "australia-southeast1", "asia-northeast1"]
|
||||
},
|
||||
"lang": "string",
|
||||
"actionHook": "object|string",
|
||||
"eventHook": "object|string",
|
||||
@@ -224,6 +287,40 @@
|
||||
"length"
|
||||
]
|
||||
},
|
||||
"rasa": {
|
||||
"properties": {
|
||||
"url": "string",
|
||||
"recognizer": "#recognizer",
|
||||
"tts": "#synthesizer",
|
||||
"prompt": "string",
|
||||
"actionHook": "object|string",
|
||||
"eventHook": "object|string"
|
||||
},
|
||||
"required": [
|
||||
"url"
|
||||
]
|
||||
},
|
||||
"record": {
|
||||
"properties": {
|
||||
"path": "string"
|
||||
},
|
||||
"required": [
|
||||
"path"
|
||||
]
|
||||
},
|
||||
"recordOptions": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["startCallRecording", "stopCallRecording", "pauseCallRecording", "resumeCallRecording"]
|
||||
},
|
||||
"recordingID": "string",
|
||||
"siprecServerURL": "string"
|
||||
},
|
||||
"required": [
|
||||
"action"
|
||||
]
|
||||
},
|
||||
"redirect": {
|
||||
"properties": {
|
||||
"actionHook": "object|string"
|
||||
@@ -270,7 +367,6 @@
|
||||
"earlyMedia": "boolean"
|
||||
},
|
||||
"required": [
|
||||
"transcriptionHook",
|
||||
"recognizer"
|
||||
]
|
||||
},
|
||||
@@ -285,12 +381,15 @@
|
||||
"type": "string",
|
||||
"enum": ["GET", "POST"]
|
||||
},
|
||||
"headers": "object",
|
||||
"name": "string",
|
||||
"number": "string",
|
||||
"sipUri": "string",
|
||||
"auth": "#auth",
|
||||
"vmail": "boolean",
|
||||
"tenant": "string"
|
||||
"tenant": "string",
|
||||
"trunk": "string",
|
||||
"overrideTo": "string"
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
@@ -310,10 +409,14 @@
|
||||
"properties": {
|
||||
"vendor": {
|
||||
"type": "string",
|
||||
"enum": ["google", "aws", "polly", "default"]
|
||||
"enum": ["google", "aws", "polly", "microsoft", "default"]
|
||||
},
|
||||
"language": "string",
|
||||
"voice": "string",
|
||||
"engine": {
|
||||
"type": "string",
|
||||
"enum": ["standard", "neural"]
|
||||
},
|
||||
"gender": {
|
||||
"type": "string",
|
||||
"enum": ["MALE", "FEMALE", "NEUTRAL"]
|
||||
@@ -327,10 +430,12 @@
|
||||
"properties": {
|
||||
"vendor": {
|
||||
"type": "string",
|
||||
"enum": ["google", "aws", "default"]
|
||||
"enum": ["google", "aws", "microsoft", "default"]
|
||||
},
|
||||
"language": "string",
|
||||
"vad": "#vad",
|
||||
"hints": "array",
|
||||
"hintsBoost": "number",
|
||||
"altLanguages": "array",
|
||||
"profanityFilter": "boolean",
|
||||
"interim": "boolean",
|
||||
@@ -367,7 +472,28 @@
|
||||
"mask",
|
||||
"tag"
|
||||
]
|
||||
}
|
||||
},
|
||||
"model": "string",
|
||||
"outputFormat": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"simple",
|
||||
"detailed"
|
||||
]
|
||||
},
|
||||
"profanityOption": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"masked",
|
||||
"removed",
|
||||
"raw"
|
||||
]
|
||||
},
|
||||
"requestSnr": "boolean",
|
||||
"initialSpeechTimeoutMs": "number",
|
||||
"azureServiceEndpoint": "string",
|
||||
"asrDtmfTerminationDigit": "string",
|
||||
"asrTimeout": "number"
|
||||
},
|
||||
"required": [
|
||||
"vendor"
|
||||
@@ -381,5 +507,15 @@
|
||||
"required": [
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"vad": {
|
||||
"properties": {
|
||||
"enable": "boolean",
|
||||
"voiceMs": "number",
|
||||
"mode": "number"
|
||||
},
|
||||
"required": [
|
||||
"enable"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ const debug = require('debug')('jambonz:feature-server');
|
||||
const assert = require('assert');
|
||||
const {TaskPreconditions} = require('../utils/constants');
|
||||
const normalizeJambones = require('../utils/normalize-jambones');
|
||||
const {trace} = require('@opentelemetry/api');
|
||||
const specs = new Map();
|
||||
const _specData = require('./specs');
|
||||
for (const key in _specData) {specs.set(key, _specData[key]);}
|
||||
@@ -23,6 +24,9 @@ class Task extends Emitter {
|
||||
|
||||
this._killInProgress = false;
|
||||
this._completionPromise = new Promise((resolve) => this._completionResolver = resolve);
|
||||
|
||||
/* used when we play a prompt to a member in conference */
|
||||
this._confPlayCompletionPromise = new Promise((resolve) => this._confPlayCompletionResolver = resolve);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,6 +43,10 @@ class Task extends Emitter {
|
||||
return this.cs;
|
||||
}
|
||||
|
||||
get summary() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.data;
|
||||
}
|
||||
@@ -59,7 +67,37 @@ class Task extends Emitter {
|
||||
kill(cs) {
|
||||
if (this.cs && !this.cs.isConfirmCallSession) this.logger.debug(`${this.name} is being killed`);
|
||||
this._killInProgress = true;
|
||||
// no-op
|
||||
|
||||
/* remove reference to parent task or else entangled parent-child tasks will not be gc'ed */
|
||||
setImmediate(() => this.parentTask = null);
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,6 +115,21 @@ class Task extends Emitter {
|
||||
return this._completionPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* when a play to conference member completes
|
||||
*/
|
||||
notifyConfPlayDone() {
|
||||
this._confPlayCompletionResolver();
|
||||
}
|
||||
|
||||
/**
|
||||
* when a subclass task has launched various async activities and is now simply waiting
|
||||
* for them to complete it should call this method to block until that happens
|
||||
*/
|
||||
awaitConfPlayDone() {
|
||||
return this._confPlayCompletionPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* provided as a convenience for tasks, this simply calls CallSession#normalizeUrl
|
||||
*/
|
||||
@@ -84,18 +137,104 @@ class Task extends Emitter {
|
||||
return this.callSession.normalizeUrl(url, method, auth);
|
||||
}
|
||||
|
||||
notifyError(errMsg) {
|
||||
const params = {error: errMsg, verb: this.name};
|
||||
this.cs.requestor.request('jambonz:error', '/error', params)
|
||||
.catch((err) => this.logger.info({err}, 'Task:notifyError error sending error'));
|
||||
}
|
||||
|
||||
async performAction(results, expectResponse = true) {
|
||||
if (this.actionHook) {
|
||||
const params = results ? Object.assign(results, this.cs.callInfo.toJSON()) : this.cs.callInfo.toJSON();
|
||||
const json = await this.cs.requestor.request(this.actionHook, params);
|
||||
if (expectResponse && json && Array.isArray(json)) {
|
||||
const span = this.startSpan('verb:hook', {'hook.url': this.actionHook});
|
||||
const b3 = this.getTracingPropagation('b3', span);
|
||||
const httpHeaders = b3 && {b3};
|
||||
span.setAttributes({'http.body': JSON.stringify(params)});
|
||||
try {
|
||||
const json = await this.cs.requestor.request('verb:hook', this.actionHook, params, httpHeaders);
|
||||
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) {
|
||||
const span = this.startSpan('verb:hook', {'hook.url': hook});
|
||||
const b3 = this.getTracingPropagation('b3', span);
|
||||
const httpHeaders = b3 && {b3};
|
||||
span.setAttributes({'http.body': JSON.stringify(results)});
|
||||
try {
|
||||
const json = await cs.requestor.request('verb:hook', hook, results, httpHeaders);
|
||||
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.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
|
||||
this.callSession.replaceApplication(tasks);
|
||||
this.redirect(cs, tasks);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
span.setAttributes({'http.statusCode': err.statusCode});
|
||||
span.end();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
redirect(cs, tasks) {
|
||||
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
|
||||
this.isReplacingApplication = true;
|
||||
cs.replaceApplication(tasks);
|
||||
}
|
||||
|
||||
async playToConfMember(ep, memberId, confName, confUuid, filepath) {
|
||||
try {
|
||||
this.logger.debug(`Task:playToConfMember - playing ${filepath} to ${confName}:${memberId}`);
|
||||
|
||||
// listen for conference events
|
||||
const handler = this.__onConferenceEvent.bind(this);
|
||||
ep.conn.on('esl::event::CUSTOM::*', handler) ;
|
||||
const response = await ep.api(`conference ${confName} play ${filepath} ${memberId}`);
|
||||
this.logger.debug({response}, 'Task:playToConfMember - api call returned');
|
||||
await this.awaitConfPlayDone();
|
||||
ep.conn.removeListener('esl::event::CUSTOM::*', handler);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, `Task:playToConfMember - error playing ${filepath} to ${confName}:${memberId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async killPlayToConfMember(ep, memberId, confName) {
|
||||
try {
|
||||
this.logger.debug(`Task:killPlayToConfMember - killing audio to ${confName}:${memberId}`);
|
||||
const response = await ep.api(`conference ${confName} stop ${memberId}`);
|
||||
this.logger.debug({response}, 'Task:killPlayToConfMember - api call returned');
|
||||
} catch (err) {
|
||||
this.logger.error({err}, `Task:killPlayToConfMember - error killing audio to ${confName}:${memberId}`);
|
||||
}
|
||||
}
|
||||
|
||||
__onConferenceEvent(evt) {
|
||||
const eventName = evt.getHeader('Event-Subclass') ;
|
||||
if (eventName === 'conference::maintenance') {
|
||||
const action = evt.getHeader('Action') ;
|
||||
if (action === 'play-file-member-done') {
|
||||
this.logger.debug('done playing file to conf member');
|
||||
this.notifyConfPlayDone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ const {
|
||||
TaskName,
|
||||
TaskPreconditions,
|
||||
GoogleTranscriptionEvents,
|
||||
AzureTranscriptionEvents,
|
||||
AwsTranscriptionEvents
|
||||
} = require('../utils/constants');
|
||||
|
||||
@@ -10,6 +11,7 @@ class TaskTranscribe extends Task {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
this.parentTask = parentTask;
|
||||
|
||||
this.transcriptionHook = this.data.transcriptionHook;
|
||||
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
|
||||
@@ -20,12 +22,19 @@ class TaskTranscribe extends Task {
|
||||
this.interim = !!recognizer.interim;
|
||||
this.separateRecognitionPerChannel = recognizer.separateRecognitionPerChannel;
|
||||
|
||||
/* vad: if provided, we dont connect to recognizer until voice activity is detected */
|
||||
const {enable, voiceMs = 0, mode = -1} = recognizer.vad || {};
|
||||
this.vad = {enable, voiceMs, mode};
|
||||
|
||||
/* google-specific options */
|
||||
this.hints = recognizer.hints || [];
|
||||
this.hintsBoost = recognizer.hintsBoost;
|
||||
this.profanityFilter = recognizer.profanityFilter;
|
||||
this.punctuation = !!recognizer.punctuation;
|
||||
this.enhancedModel = !!recognizer.enhancedModel;
|
||||
this.model = recognizer.model || 'phone_call';
|
||||
this.words = !!recognizer.words;
|
||||
this.singleUtterance = recognizer.singleUtterance || false;
|
||||
this.diarization = !!recognizer.diarization;
|
||||
this.diarizationMinSpeakers = recognizer.diarizationMinSpeakers || 0;
|
||||
this.diarizationMaxSpeakers = recognizer.diarizationMaxSpeakers || 0;
|
||||
@@ -38,31 +47,48 @@ class TaskTranscribe extends Task {
|
||||
this.vocabularyName = recognizer.vocabularyName;
|
||||
this.vocabularyFilterName = recognizer.vocabularyFilterName;
|
||||
this.filterMethod = recognizer.filterMethod;
|
||||
|
||||
/* microsoft options */
|
||||
this.outputFormat = recognizer.outputFormat || 'simple';
|
||||
this.profanityOption = recognizer.profanityOption || 'raw';
|
||||
this.requestSnr = recognizer.requestSnr || false;
|
||||
this.initialSpeechTimeoutMs = recognizer.initialSpeechTimeoutMs || 0;
|
||||
this.azureServiceEndpoint = recognizer.azureServiceEndpoint;
|
||||
}
|
||||
|
||||
get name() { return TaskName.Transcribe; }
|
||||
|
||||
async exec(cs, ep, parentTask) {
|
||||
async exec(cs, ep, ep2) {
|
||||
super.exec(cs);
|
||||
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
|
||||
|
||||
this.ep = ep;
|
||||
this.ep2 = ep2;
|
||||
if ('default' === this.vendor || !this.vendor) this.vendor = cs.speechRecognizerVendor;
|
||||
if ('default' === this.language || !this.language) this.language = cs.speechRecognizerLanguage;
|
||||
this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt');
|
||||
|
||||
try {
|
||||
if (!this.sttCredentials) {
|
||||
// TODO: generate alert (actually should be done by cs.getSpeechCredentials)
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
this.logger.info(`TaskTranscribe:exec - ERROR stt using ${this.vendor} requested but creds not supplied`);
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.STT_NOT_PROVISIONED,
|
||||
vendor: this.vendor
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for no stt'));
|
||||
throw new Error('no provisioned speech credentials for TTS');
|
||||
}
|
||||
await this._startTranscribing(cs, ep);
|
||||
await this._startTranscribing(cs, ep, 1);
|
||||
if (this.separateRecognitionPerChannel && ep2) await this._startTranscribing(cs, ep2, 2);
|
||||
|
||||
updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid)
|
||||
.catch(() => {/*already logged error */});
|
||||
|
||||
await this.awaitTaskDone();
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'TaskTranscribe:exec - error');
|
||||
this.parentTask && this.parentTask.emit('error', err);
|
||||
}
|
||||
ep.removeCustomEventListener(GoogleTranscriptionEvents.Transcription);
|
||||
ep.removeCustomEventListener(GoogleTranscriptionEvents.NoAudioDetected);
|
||||
@@ -70,6 +96,8 @@ class TaskTranscribe extends Task {
|
||||
ep.removeCustomEventListener(AwsTranscriptionEvents.Transcription);
|
||||
ep.removeCustomEventListener(AwsTranscriptionEvents.NoAudioDetected);
|
||||
ep.removeCustomEventListener(AwsTranscriptionEvents.MaxDurationExceeded);
|
||||
ep.removeCustomEventListener(AzureTranscriptionEvents.Transcription);
|
||||
ep.removeCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected);
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
@@ -81,47 +109,60 @@ class TaskTranscribe extends Task {
|
||||
// hangup after 1 sec if we don't get a final transcription
|
||||
this._timer = setTimeout(() => this.notifyTaskDone(), 1000);
|
||||
}
|
||||
if (this.separateRecognitionPerChannel && this.ep2 && this.ep2.connected) {
|
||||
this.ep2.stopTranscription({vendor: this.vendor})
|
||||
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
||||
}
|
||||
else this.notifyTaskDone();
|
||||
await this.awaitTaskDone();
|
||||
}
|
||||
|
||||
async _startTranscribing(cs, ep) {
|
||||
async _startTranscribing(cs, ep, channel) {
|
||||
const opts = {};
|
||||
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, cs, ep));
|
||||
if (this.vad.enable) {
|
||||
opts.START_RECOGNIZING_ON_VAD = 1;
|
||||
if (this.vad.voiceMs) opts.RECOGNIZER_VAD_VOICE_MS = this.vad.voiceMs;
|
||||
if (this.vad.mode >= 0 && this.vad.mode <= 3) opts.RECOGNIZER_VAD_MODE = this.vad.mode;
|
||||
}
|
||||
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.MaxDurationExceeded,
|
||||
this._onMaxDurationExceeded.bind(this, ep));
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, cs, ep));
|
||||
this._onMaxDurationExceeded.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, 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));
|
||||
this._onMaxDurationExceeded.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected, this._onNoAudio.bind(this, cs, ep, channel));
|
||||
|
||||
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'],
|
||||
//['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'],
|
||||
['singleUtterance', 'GOOGLE_SPEECH_SINGLE_UTTERANCE'],
|
||||
['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.hints.length > 1) {
|
||||
opts.GOOGLE_SPEECH_HINTS = this.hints.join(',');
|
||||
if (typeof this.hintsBoost === 'number') {
|
||||
opts.GOOGLE_SPEECH_HINTS_BOOST = this.hintsBoost;
|
||||
}
|
||||
}
|
||||
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';
|
||||
opts.GOOGLE_SPEECH_MODEL = this.model;
|
||||
if (this.diarization && this.diarizationMinSpeakers > 0) {
|
||||
opts.GOOGLE_SPEECH_SPEAKER_DIARIZATION_MIN_SPEAKER_COUNT = this.diarizationMinSpeakers;
|
||||
}
|
||||
@@ -164,6 +205,24 @@ class TaskTranscribe extends Task {
|
||||
await ep.set(opts)
|
||||
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with aws'));
|
||||
}
|
||||
else if (this.vendor === 'microsoft') {
|
||||
Object.assign(opts, {
|
||||
'AZURE_SUBSCRIPTION_KEY': this.sttCredentials.api_key,
|
||||
'AZURE_REGION': this.sttCredentials.region
|
||||
});
|
||||
if (this.hints && this.hints.length > 1) {
|
||||
opts.AZURE_SPEECH_HINTS = this.hints.map((h) => h.trim()).join(',');
|
||||
}
|
||||
if (this.altLanguages.length > 1) opts.AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.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;
|
||||
if (this.azureServiceEndpoint) opts.AZURE_SERVICE_ENDPOINT = this.azureServiceEndpoint;
|
||||
|
||||
await ep.set(opts)
|
||||
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with azure'));
|
||||
}
|
||||
await this._transcribe(ep);
|
||||
}
|
||||
|
||||
@@ -172,16 +231,54 @@ class TaskTranscribe extends Task {
|
||||
vendor: this.vendor,
|
||||
interim: this.interim ? true : false,
|
||||
locale: this.language,
|
||||
channels: this.separateRecognitionPerChannel ? 2 : 1
|
||||
channels: /*this.separateRecognitionPerChannel ? 2 : */ 1
|
||||
});
|
||||
}
|
||||
|
||||
_onTranscription(cs, ep, evt) {
|
||||
_onTranscription(cs, ep, channel, evt) {
|
||||
this.logger.debug({evt, channel}, 'TaskTranscribe:_onTranscription');
|
||||
if ('aws' === this.vendor && Array.isArray(evt) && evt.length > 0) evt = evt[0];
|
||||
this.logger.debug(evt, 'TaskTranscribe:_onTranscription');
|
||||
if ('microsoft' === this.vendor) {
|
||||
const nbest = evt.NBest;
|
||||
const language_code = evt.PrimaryLanguage?.Language || this.language;
|
||||
const alternatives = nbest ? nbest.map((n) => {
|
||||
return {
|
||||
confidence: n.Confidence,
|
||||
transcript: n.Display
|
||||
};
|
||||
}) :
|
||||
[
|
||||
{
|
||||
transcript: evt.DisplayText
|
||||
}
|
||||
];
|
||||
|
||||
this.cs.requestor.request(this.transcriptionHook, Object.assign({speech: evt}, this.cs.callInfo))
|
||||
.catch((err) => this.logger.info(err, 'TranscribeTask:_onTranscription error'));
|
||||
const newEvent = {
|
||||
is_final: evt.RecognitionStatus === 'Success',
|
||||
channel,
|
||||
language_code,
|
||||
alternatives
|
||||
};
|
||||
evt = newEvent;
|
||||
}
|
||||
|
||||
if (evt.alternatives[0].transcript === '' && !cs.callGone && !this.killed) {
|
||||
this.logger.info({evt}, 'TaskGather:_onTranscription - got empty transcript, listen again');
|
||||
return this._transcribe(ep);
|
||||
}
|
||||
|
||||
evt.channel_tag = channel;
|
||||
|
||||
if (this.transcriptionHook) {
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
this.cs.requestor.request('verb:hook', this.transcriptionHook,
|
||||
Object.assign({speech: evt}, this.cs.callInfo), httpHeaders)
|
||||
.catch((err) => this.logger.info(err, 'TranscribeTask:_onTranscription error'));
|
||||
}
|
||||
if (this.parentTask) {
|
||||
this.parentTask.emit('transcription', evt);
|
||||
}
|
||||
if (this.killed) {
|
||||
this.logger.debug('TaskTranscribe:_onTranscription exiting after receiving final transcription');
|
||||
this._clearTimer();
|
||||
@@ -189,13 +286,13 @@ class TaskTranscribe extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
_onNoAudio(cs, ep) {
|
||||
this.logger.debug('TaskTranscribe:_onNoAudio restarting transcription');
|
||||
_onNoAudio(cs, ep, channel) {
|
||||
this.logger.debug(`TaskTranscribe:_onNoAudio restarting transcription on channel ${channel}`);
|
||||
this._transcribe(ep);
|
||||
}
|
||||
|
||||
_onMaxDurationExceeded(cs, ep) {
|
||||
this.logger.debug('TaskTranscribe:_onMaxDurationExceeded restarting transcription');
|
||||
_onMaxDurationExceeded(cs, ep, channel) {
|
||||
this.logger.debug(`TaskTranscribe:_onMaxDurationExceeded restarting transcription on channel ${channel}`);
|
||||
this._transcribe(ep);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ class SnsNotifier extends Emitter {
|
||||
}, 'response from SNS SubscribeURL');
|
||||
const data = await this.describeInstance();
|
||||
this.lifecycleState = data.AutoScalingInstances[0].LifecycleState;
|
||||
this.emit('SubscriptionConfirmation', {publicIp: this.publicIp});
|
||||
break;
|
||||
|
||||
case 'Notification':
|
||||
|
||||
75
lib/utils/base-requestor.js
Normal file
75
lib/utils/base-requestor.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const assert = require('assert');
|
||||
const Emitter = require('events');
|
||||
const crypto = require('crypto');
|
||||
const timeSeries = require('@jambonz/time-series');
|
||||
let alerter ;
|
||||
|
||||
class BaseRequestor extends Emitter {
|
||||
constructor(logger, account_sid, hook, secret) {
|
||||
super();
|
||||
assert(typeof hook === 'object');
|
||||
|
||||
this.logger = logger;
|
||||
this.url = hook.url;
|
||||
|
||||
this.username = hook.username;
|
||||
this.password = hook.password;
|
||||
this.secret = secret;
|
||||
this.account_sid = account_sid;
|
||||
|
||||
const {stats} = require('../../').srf.locals;
|
||||
this.stats = stats;
|
||||
|
||||
if (!alerter) {
|
||||
alerter = timeSeries(logger, {
|
||||
host: process.env.JAMBONES_TIME_SERIES_HOST,
|
||||
commitSize: 50,
|
||||
commitInterval: 'test' === process.env.NODE_ENV ? 7 : 20
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get Alerter() {
|
||||
return alerter;
|
||||
}
|
||||
|
||||
close() {
|
||||
/* subclass responsibility */
|
||||
}
|
||||
|
||||
_computeSignature(payload, timestamp, secret) {
|
||||
assert(secret);
|
||||
const data = `${timestamp}.${JSON.stringify(payload)}`;
|
||||
return crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(data, 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
_generateSigHeader(payload, secret) {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const signature = this._computeSignature(payload, timestamp, secret);
|
||||
const scheme = 'v1';
|
||||
return {
|
||||
'Jambonz-Signature': `t=${timestamp},${scheme}=${signature}`
|
||||
};
|
||||
}
|
||||
|
||||
_isAbsoluteUrl(u) {
|
||||
return typeof u === 'string' &&
|
||||
u.startsWith('https://') || u.startsWith('http://') ||
|
||||
u.startsWith('ws://') || u.startsWith('wss://');
|
||||
}
|
||||
_isRelativeUrl(u) {
|
||||
return typeof u === 'string' && u.startsWith('/');
|
||||
}
|
||||
_roundTrip(startAt) {
|
||||
const diff = process.hrtime(startAt);
|
||||
const time = diff[0] * 1e3 + diff[1] * 1e-6;
|
||||
return time.toFixed(0);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
module.exports = BaseRequestor;
|
||||
78
lib/utils/call-tracer.js
Normal file
78
lib/utils/call-tracer.js
Normal file
@@ -0,0 +1,78 @@
|
||||
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;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"TaskName": {
|
||||
"Cognigy": "cognigy",
|
||||
"Conference": "conference",
|
||||
"Config": "config",
|
||||
"Dequeue": "dequeue",
|
||||
"Dial": "dial",
|
||||
"Dialogflow": "dialogflow",
|
||||
@@ -14,9 +16,12 @@
|
||||
"Message": "message",
|
||||
"Pause": "pause",
|
||||
"Play": "play",
|
||||
"Rasa": "rasa",
|
||||
"Redirect": "redirect",
|
||||
"RestDial": "rest:dial",
|
||||
"SipDecline": "sip:decline",
|
||||
"SipRequest": "sip:request",
|
||||
"SipRefer": "sip:refer",
|
||||
"SipNotify": "sip:notify",
|
||||
"SipRedirect": "sip:redirect",
|
||||
"Say": "say",
|
||||
@@ -55,13 +60,22 @@
|
||||
"Transcription": "google_transcribe::transcription",
|
||||
"EndOfUtterance": "google_transcribe::end_of_utterance",
|
||||
"NoAudioDetected": "google_transcribe::no_audio_detected",
|
||||
"MaxDurationExceeded": "google_transcribe::max_duration_exceeded"
|
||||
"MaxDurationExceeded": "google_transcribe::max_duration_exceeded",
|
||||
"VadDetected": "google_transcribe::vad_detected"
|
||||
},
|
||||
"AwsTranscriptionEvents": {
|
||||
"Transcription": "aws_transcribe::transcription",
|
||||
"EndOfTranscript": "aws_transcribe::end_of_transcript",
|
||||
"NoAudioDetected": "aws_transcribe::no_audio_detected",
|
||||
"MaxDurationExceeded": "aws_transcribe::max_duration_exceeded"
|
||||
"MaxDurationExceeded": "aws_transcribe::max_duration_exceeded",
|
||||
"VadDetected": "aws_transcribe::vad_detected"
|
||||
},
|
||||
"AzureTranscriptionEvents": {
|
||||
"Transcription": "azure_transcribe::transcription",
|
||||
"StartOfUtterance": "azure_transcribe::start_of_utterance",
|
||||
"EndOfUtterance": "azure_transcribe::end_of_utterance",
|
||||
"NoSpeechDetected": "azure_transcribe::no_speech_detected",
|
||||
"VadDetected": "azure_transcribe::vad_detected"
|
||||
},
|
||||
"ListenEvents": {
|
||||
"Connect": "mod_audio_fork::connect",
|
||||
@@ -92,6 +106,26 @@
|
||||
"Hangup": "hangup",
|
||||
"Timeout": "timeout"
|
||||
},
|
||||
"KillReason": {
|
||||
"Hangup": "hangup",
|
||||
"Replaced": "replaced"
|
||||
},
|
||||
"HookMsgTypes": [
|
||||
"session:new",
|
||||
"session:reconnect",
|
||||
"session:redirect",
|
||||
"call:status",
|
||||
"queue:status",
|
||||
"dial:confirm",
|
||||
"verb:hook",
|
||||
"jambonz:error"
|
||||
],
|
||||
"RecordState": {
|
||||
"RecordingOn": "recording_on",
|
||||
"RecordingOff": "recording_off",
|
||||
"RecordingPaused": "recording_paused"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
|
||||
52
lib/utils/cron-jobs.js
Normal file
52
lib/utils/cron-jobs.js
Normal file
@@ -0,0 +1,52 @@
|
||||
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};
|
||||
|
||||
@@ -10,6 +10,16 @@ const sqlSpeechCredentialsForSP = `SELECT *
|
||||
FROM speech_credentials
|
||||
WHERE service_provider_sid =
|
||||
(SELECT service_provider_sid from accounts where account_sid = ?)`;
|
||||
const sqlQueryAccountCarrierByName = `SELECT voip_carrier_sid
|
||||
FROM voip_carriers vc
|
||||
WHERE vc.account_sid = ?
|
||||
AND vc.name = ?`;
|
||||
const sqlQuerySPCarrierByName = `SELECT voip_carrier_sid
|
||||
FROM voip_carriers vc
|
||||
WHERE vc.account_sid IS NULL
|
||||
AND vc.service_provider_sid =
|
||||
(SELECT service_provider_sid from accounts where account_sid = ?)
|
||||
AND vc.name = ?`;
|
||||
|
||||
const speechMapper = (cred) => {
|
||||
const {credential, ...obj} = cred;
|
||||
@@ -21,6 +31,15 @@ const speechMapper = (cred) => {
|
||||
obj.access_key_id = o.access_key_id;
|
||||
obj.secret_access_key = o.secret_access_key;
|
||||
}
|
||||
else if ('microsoft' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.region = o.region;
|
||||
}
|
||||
else if ('wellsaid' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
@@ -29,6 +48,7 @@ module.exports = (logger, srf) => {
|
||||
const pp = pool.promise();
|
||||
|
||||
const lookupAccountDetails = async(account_sid) => {
|
||||
|
||||
const [r] = await pp.query({sql: sqlAccountDetails, nestTables: true}, account_sid);
|
||||
if (0 === r.length) throw new Error(`invalid accountSid: ${account_sid}`);
|
||||
const [r2] = await pp.query(sqlSpeechCredentials, account_sid);
|
||||
@@ -37,7 +57,9 @@ module.exports = (logger, srf) => {
|
||||
/* search at the service provider level if we don't find it at the account level */
|
||||
const haveGoogle = speech.find((s) => s.vendor === 'google');
|
||||
const haveAws = speech.find((s) => s.vendor === 'aws');
|
||||
if (!haveGoogle || !haveAws) {
|
||||
const haveMicrosoft = speech.find((s) => s.vendor === 'microsoft');
|
||||
const haveWellsaid = speech.find((s) => s.vendor === 'wellsaid');
|
||||
if (!haveGoogle || !haveAws || !haveMicrosoft) {
|
||||
const [r3] = await pp.query(sqlSpeechCredentialsForSP, account_sid);
|
||||
if (r3.length) {
|
||||
if (!haveGoogle) {
|
||||
@@ -48,6 +70,14 @@ module.exports = (logger, srf) => {
|
||||
const aws = r3.find((s) => s.vendor === 'aws');
|
||||
if (aws) speech.push(speechMapper(aws));
|
||||
}
|
||||
if (!haveMicrosoft) {
|
||||
const ms = r3.find((s) => s.vendor === 'microsoft');
|
||||
if (ms) speech.push(speechMapper(ms));
|
||||
}
|
||||
if (!haveWellsaid) {
|
||||
const wellsaid = r3.find((s) => s.vendor === 'wellsaid');
|
||||
if (wellsaid) speech.push(speechMapper(wellsaid));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,8 +97,21 @@ module.exports = (logger, srf) => {
|
||||
}
|
||||
};
|
||||
|
||||
const lookupCarrier = async(account_sid, carrierName) => {
|
||||
const pp = pool.promise();
|
||||
try {
|
||||
const [r] = await pp.query(sqlQueryAccountCarrierByName, [account_sid, carrierName]);
|
||||
if (r.length) return r[0].voip_carrier_sid;
|
||||
const [r2] = await pp.query(sqlQuerySPCarrierByName, [account_sid, carrierName]);
|
||||
if (r2.length) return r2[0].voip_carrier_sid;
|
||||
} catch (err) {
|
||||
logger.error({err}, `lookupCarrier: Error ${account_sid}:${carrierName}`);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
lookupAccountDetails,
|
||||
updateSpeechCredentialLastUsed
|
||||
updateSpeechCredentialLastUsed,
|
||||
lookupCarrier
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const crypto = require('crypto');
|
||||
const algorithm = 'aes-256-ctr';
|
||||
const algorithm = process.env.LEGACY_CRYPTO ? 'aes-256-ctr' : 'aes-256-cbc';
|
||||
const iv = crypto.randomBytes(16);
|
||||
const secretKey = crypto.createHash('sha256')
|
||||
.update(String(process.env.JWT_SECRET))
|
||||
|
||||
115
lib/utils/http-requestor.js
Normal file
115
lib/utils/http-requestor.js
Normal file
@@ -0,0 +1,115 @@
|
||||
const bent = require('bent');
|
||||
const parseUrl = require('parse-url');
|
||||
const assert = require('assert');
|
||||
const BaseRequestor = require('./base-requestor');
|
||||
const {HookMsgTypes} = require('./constants.json');
|
||||
const snakeCaseKeys = require('./snakecase-keys');
|
||||
|
||||
const toBase64 = (str) => Buffer.from(str || '', 'utf8').toString('base64');
|
||||
|
||||
function basicAuth(username, password) {
|
||||
if (!username || !password) return {};
|
||||
const creds = `${username}:${password || ''}`;
|
||||
const header = `Basic ${toBase64(creds)}`;
|
||||
return {Authorization: header};
|
||||
}
|
||||
|
||||
|
||||
class HttpRequestor extends BaseRequestor {
|
||||
constructor(logger, account_sid, hook, secret) {
|
||||
super(logger, account_sid, hook, secret);
|
||||
|
||||
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);
|
||||
|
||||
|
||||
assert(this._isAbsoluteUrl(this.url));
|
||||
assert(['GET', 'POST'].includes(this.method));
|
||||
}
|
||||
|
||||
get baseUrl() {
|
||||
return this._baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(type, hook, params, httpHeaders = {}) {
|
||||
/* jambonz:error only sent over ws */
|
||||
if (type === 'jambonz:error') return;
|
||||
|
||||
assert(HookMsgTypes.includes(type));
|
||||
|
||||
const payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null;
|
||||
const url = hook.url || hook;
|
||||
const method = hook.method || 'POST';
|
||||
|
||||
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}`);
|
||||
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();
|
||||
|
||||
let buf;
|
||||
try {
|
||||
const sigHeader = this._generateSigHeader(payload, this.secret);
|
||||
const headers = {...sigHeader, ...this.authHeader, ...httpHeaders};
|
||||
this.logger.debug({url, headers}, 'send webhook');
|
||||
buf = this._isRelativeUrl(url) ?
|
||||
await this.post(url, payload, headers) :
|
||||
await bent(method, 'buffer', 200, 201, 202)(url, payload, headers);
|
||||
} catch (err) {
|
||||
if (err.statusCode) {
|
||||
this.logger.info({baseUrl: this.baseUrl, url},
|
||||
`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};
|
||||
if (err.code === 'ECONNREFUSED') {
|
||||
opts = {...opts, alert_type: this.Alerter.AlertType.WEBHOOK_CONNECTION_FAILURE, url};
|
||||
}
|
||||
else if (err.name === 'StatusError') {
|
||||
opts = {...opts, alert_type: this.Alerter.AlertType.WEBHOOK_STATUS_FAILURE, url, status: err.statusCode};
|
||||
}
|
||||
else {
|
||||
opts = {...opts, alert_type: this.Alerter.AlertType.WEBHOOK_CONNECTION_FAILURE, url, detail: err.message};
|
||||
}
|
||||
this.Alerter.writeAlerts(opts).catch((err) => this.logger.info({err, opts}, 'Error writing alert'));
|
||||
|
||||
throw err;
|
||||
}
|
||||
const rtt = this._roundTrip(startAt);
|
||||
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}, `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()}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = HttpRequestor;
|
||||
@@ -1,6 +1,5 @@
|
||||
const Mrf = require('drachtio-fsmrf');
|
||||
const ip = require('ip');
|
||||
const localIp = ip.address();
|
||||
const PORT = process.env.HTTP_PORT || 3000;
|
||||
const assert = require('assert');
|
||||
|
||||
@@ -20,11 +19,19 @@ function initMS(logger, wrapper, ms) {
|
||||
wrapper.connects = 1;
|
||||
wrapper.active = true;
|
||||
});
|
||||
|
||||
ms.on('channel::open', (evt) => {
|
||||
logger.debug({evt}, `mediaserver ${ms.address} added endpoint`);
|
||||
});
|
||||
ms.on('channel::close', (evt) => {
|
||||
logger.debug({evt}, `mediaserver ${ms.address} removed endpoint`);
|
||||
});
|
||||
}
|
||||
|
||||
function installSrfLocals(srf, logger) {
|
||||
logger.debug('installing srf locals');
|
||||
assert(!srf.locals.dbHelpers);
|
||||
const {tracer} = srf.locals.otel;
|
||||
const {getSBC, lifecycleEmitter} = require('./sbc-pinger')(logger);
|
||||
const StatsCollector = require('@jambonz/stats-collector');
|
||||
const stats = srf.locals.stats = new StatsCollector(logger);
|
||||
@@ -42,7 +49,11 @@ function installSrfLocals(srf, logger) {
|
||||
assert.ok(arr, `Invalid syntax JAMBONES_FREESWITCH: ${process.env.JAMBONES_FREESWITCH}`);
|
||||
const opts = {address: arr[1], port: arr[2], secret: arr[3]};
|
||||
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';
|
||||
else if (process.env.JAMBONES_ESL_LISTEN_ADDRESS) opts.listenAddress = process.env.JAMBONES_ESL_LISTEN_ADDRESS;
|
||||
return opts;
|
||||
});
|
||||
logger.info({fsInventory}, 'freeswitch inventory');
|
||||
@@ -55,7 +66,7 @@ function installSrfLocals(srf, logger) {
|
||||
initMS(logger, val, ms);
|
||||
}
|
||||
catch (err) {
|
||||
logger.info(`failed connecting to freeswitch at ${fs.address}, will retry shortly`);
|
||||
logger.info({err}, `failed connecting to freeswitch at ${fs.address}, will retry shortly: ${err.message}`);
|
||||
}
|
||||
}
|
||||
// retry to connect to any that were initially offline
|
||||
@@ -67,7 +78,7 @@ function installSrfLocals(srf, logger) {
|
||||
const ms = await mrf.connect(val.opts);
|
||||
initMS(logger, val, ms);
|
||||
} catch (err) {
|
||||
logger.info(`failed connecting to freeswitch at ${val.opts.address}, will retry shortly`);
|
||||
logger.info({err}, `failed connecting to freeswitch at ${val.opts.address}, will retry shortly`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +116,7 @@ function installSrfLocals(srf, logger) {
|
||||
const {
|
||||
pool,
|
||||
lookupAppByPhoneNumber,
|
||||
lookupAppByRegex,
|
||||
lookupAppBySid,
|
||||
lookupAppByRealm,
|
||||
lookupAppByTeamsTenant,
|
||||
@@ -119,7 +131,7 @@ function installSrfLocals(srf, logger) {
|
||||
password: process.env.JAMBONES_MYSQL_PASSWORD,
|
||||
database: process.env.JAMBONES_MYSQL_DATABASE,
|
||||
connectionLimit: process.env.JAMBONES_MYSQL_CONNECTION_LIMIT || 10
|
||||
}, logger);
|
||||
}, logger, tracer);
|
||||
const {
|
||||
client,
|
||||
updateCallStatus,
|
||||
@@ -135,6 +147,7 @@ function installSrfLocals(srf, logger) {
|
||||
retrieveSet,
|
||||
addToSet,
|
||||
removeFromSet,
|
||||
monitorSet,
|
||||
pushBack,
|
||||
popFront,
|
||||
removeFromList,
|
||||
@@ -143,7 +156,7 @@ function installSrfLocals(srf, logger) {
|
||||
} = require('@jambonz/realtimedb-helpers')({
|
||||
host: process.env.JAMBONES_REDIS_HOST,
|
||||
port: process.env.JAMBONES_REDIS_PORT || 6379
|
||||
}, logger);
|
||||
}, logger, tracer);
|
||||
const {
|
||||
writeAlerts,
|
||||
AlertType
|
||||
@@ -153,11 +166,19 @@ function installSrfLocals(srf, logger) {
|
||||
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,
|
||||
dbHelpers: {
|
||||
client,
|
||||
pool,
|
||||
lookupAppByPhoneNumber,
|
||||
lookupAppByRegex,
|
||||
lookupAppBySid,
|
||||
lookupAppByRealm,
|
||||
lookupAppByTeamsTenant,
|
||||
@@ -178,6 +199,7 @@ function installSrfLocals(srf, logger) {
|
||||
retrieveSet,
|
||||
addToSet,
|
||||
removeFromSet,
|
||||
monitorSet,
|
||||
pushBack,
|
||||
popFront,
|
||||
removeFromList,
|
||||
@@ -185,8 +207,6 @@ function installSrfLocals(srf, logger) {
|
||||
getListPosition
|
||||
},
|
||||
parentLogger: logger,
|
||||
ipv4: localIp,
|
||||
serviceUrl: `http://${localIp}:${PORT}`,
|
||||
getSBC,
|
||||
getSmpp: () => {
|
||||
return process.env.SMPP_URL;
|
||||
@@ -197,6 +217,11 @@ function installSrfLocals(srf, logger) {
|
||||
writeAlerts,
|
||||
AlertType
|
||||
};
|
||||
|
||||
if (localIp) {
|
||||
srf.locals.ipv4 = localIp;
|
||||
srf.locals.serviceUrl = `http://${localIp}:${PORT}`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = installSrfLocals;
|
||||
|
||||
@@ -4,20 +4,18 @@ const SipError = require('drachtio-srf').SipError;
|
||||
const {TaskPreconditions, CallDirection} = require('../utils/constants');
|
||||
const CallInfo = require('../session/call-info');
|
||||
const assert = require('assert');
|
||||
const normalizeJambones = require('../utils/normalize-jambones');
|
||||
const makeTask = require('../tasks/make_task');
|
||||
const ConfirmCallSession = require('../session/confirm-call-session');
|
||||
const selectSbc = require('./select-sbc');
|
||||
const Registrar = require('@jambonz/mw-registrar');
|
||||
const AdultingCallSession = require('../session/adulting-call-session');
|
||||
const registrar = new Registrar({
|
||||
host: process.env.JAMBONES_REDIS_HOST,
|
||||
port: process.env.JAMBONES_REDIS_PORT || 6379
|
||||
});
|
||||
const deepcopy = require('deepcopy');
|
||||
const moment = require('moment');
|
||||
const stripCodecs = require('./strip-ancillary-codecs');
|
||||
const RootSpan = require('./call-tracer');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
class SingleDialer extends Emitter {
|
||||
constructor({logger, sbcAddress, target, opts, application, callInfo}) {
|
||||
constructor({logger, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan}) {
|
||||
super();
|
||||
assert(target.type);
|
||||
|
||||
@@ -27,10 +25,14 @@ class SingleDialer extends Emitter {
|
||||
this.opts = opts;
|
||||
this.application = application;
|
||||
this.confirmHook = target.confirmHook;
|
||||
this.rootSpan = rootSpan;
|
||||
this.startSpan = startSpan;
|
||||
|
||||
this.bindings = logger.bindings();
|
||||
|
||||
this.parentCallInfo = callInfo;
|
||||
this.accountInfo = accountInfo;
|
||||
|
||||
this.callGone = false;
|
||||
|
||||
this.callSid = uuidv4();
|
||||
@@ -61,8 +63,20 @@ class SingleDialer extends Emitter {
|
||||
async exec(srf, ms, opts) {
|
||||
opts = opts || {};
|
||||
opts.headers = opts.headers || {};
|
||||
opts.headers = {...opts.headers, 'X-Call-Sid': this.callSid};
|
||||
let uri, to;
|
||||
opts.headers = {
|
||||
...opts.headers,
|
||||
...(this.target.headers || {}),
|
||||
'X-Jambonz-Routing': this.target.type,
|
||||
'X-Call-Sid': this.callSid
|
||||
};
|
||||
if (srf.locals.fsUUID) {
|
||||
opts.headers = {
|
||||
...opts.headers,
|
||||
'X-Jambonz-FS-UUID': srf.locals.fsUUID,
|
||||
};
|
||||
}
|
||||
this.ms = ms;
|
||||
let uri, to, inviteSpan;
|
||||
try {
|
||||
switch (this.target.type) {
|
||||
case 'phone':
|
||||
@@ -81,18 +95,13 @@ class SingleDialer extends Emitter {
|
||||
break;
|
||||
case 'user':
|
||||
assert(this.target.name);
|
||||
const aor = this.target.name;
|
||||
uri = `sip:${this.target.name}`;
|
||||
to = this.target.name;
|
||||
|
||||
// need to send to the SBC registered on
|
||||
const reg = await registrar.query(aor);
|
||||
if (reg) {
|
||||
const sbc = selectSbc(reg.sbcAddress);
|
||||
if (sbc) {
|
||||
this.logger.debug(`SingleDialer:exec retrieved registration details for ${aor}, using sbc at ${sbc}`);
|
||||
this.sbcAddress = sbc;
|
||||
}
|
||||
if (this.target.overrideTo) {
|
||||
Object.assign(opts.headers, {
|
||||
'X-Override-To': this.target.overrideTo
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'sip':
|
||||
@@ -133,25 +142,38 @@ class SingleDialer extends Emitter {
|
||||
localSdp: this.ep.local.sdp
|
||||
});
|
||||
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}, {
|
||||
cbRequest: (err, req) => {
|
||||
if (err) {
|
||||
this.logger.error(err, 'SingleDialer:exec Error creating call');
|
||||
this.emit('callCreateFail', err);
|
||||
inviteSpan.setAttributes({
|
||||
'invite.status_code': 500,
|
||||
'invite.err': err.message
|
||||
});
|
||||
inviteSpan.end();
|
||||
return;
|
||||
}
|
||||
inviteSpan.setAttributes({'invite.call_id': req.get('Call-ID')});
|
||||
|
||||
/**
|
||||
* INVITE has been sent out
|
||||
* (a) create a CallInfo for this call
|
||||
* (a) create a logger for this call
|
||||
*/
|
||||
req.srf = srf;
|
||||
this.callInfo = new CallInfo({
|
||||
direction: CallDirection.Outbound,
|
||||
parentCallInfo: this.parentCallInfo,
|
||||
req,
|
||||
to,
|
||||
callSid: this.callSid
|
||||
callSid: this.callSid,
|
||||
traceId: this.rootSpan.traceId
|
||||
});
|
||||
this.logger = srf.locals.parentLogger.child({
|
||||
callSid: this.callSid,
|
||||
@@ -159,10 +181,14 @@ class SingleDialer extends Emitter {
|
||||
callId: this.callInfo.callId
|
||||
});
|
||||
this.inviteInProgress = req;
|
||||
this.emit('callStatusChange', {callStatus: CallStatus.Trying, sipStatus: 100});
|
||||
this.emit('callStatusChange', {
|
||||
callStatus: CallStatus.Trying,
|
||||
sipStatus: 100,
|
||||
sipReason: 'Trying'
|
||||
});
|
||||
},
|
||||
cbProvisional: (prov) => {
|
||||
const status = {sipStatus: prov.status};
|
||||
const status = {sipStatus: prov.status, sipReason: prov.reason};
|
||||
if ([180, 183].includes(prov.status) && prov.body) {
|
||||
if (status.callStatus !== CallStatus.EarlyMedia) {
|
||||
status.callStatus = CallStatus.EarlyMedia;
|
||||
@@ -177,41 +203,80 @@ class SingleDialer extends Emitter {
|
||||
await connectStream(this.dlg.remote.sdp);
|
||||
this.dlg.callSid = this.callSid;
|
||||
this.inviteInProgress = null;
|
||||
this.emit('callStatusChange', {sipStatus: 200, callStatus: CallStatus.InProgress});
|
||||
this.emit('callStatusChange', {
|
||||
sipStatus: 200,
|
||||
sipReason: 'OK',
|
||||
callStatus: CallStatus.InProgress
|
||||
});
|
||||
this.logger.debug(`SingleDialer:exec call connected: ${this.callSid}`);
|
||||
const connectTime = this.dlg.connectTime = moment();
|
||||
inviteSpan.setAttributes({'invite.status_code': 200});
|
||||
inviteSpan.end();
|
||||
|
||||
|
||||
/* race condition: we were killed just as call was answered */
|
||||
if (this.killed) {
|
||||
this.logger.info(`SingleDialer:exec race condition - we were killed as call connected: ${this.callSid}`);
|
||||
const duration = moment().diff(connectTime, 'seconds');
|
||||
this.emit('callStatusChange', {
|
||||
callStatus: CallStatus.Completed,
|
||||
sipStatus: 487,
|
||||
sipReason: 'Request Terminated',
|
||||
duration
|
||||
});
|
||||
if (this.ep) this.ep.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
this.dlg
|
||||
.on('destroy', () => {
|
||||
const duration = moment().diff(connectTime, 'seconds');
|
||||
this.logger.debug('SingleDialer:exec called party hung up');
|
||||
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||
this.ep.destroy();
|
||||
this.ep && this.ep.destroy();
|
||||
})
|
||||
.on('refresh', () => this.logger.info('SingleDialer:exec - dialog refreshed by uas'))
|
||||
.on('modify', async(req, res) => {
|
||||
try {
|
||||
const newSdp = await this.ep.modify(req.body);
|
||||
res.send(200, {body: newSdp});
|
||||
this.logger.info({offer: req.body, answer: newSdp}, 'SingleDialer:exec: handling reINVITE');
|
||||
if (this.ep) {
|
||||
const newSdp = await this.ep.modify(req.body);
|
||||
res.send(200, {body: newSdp});
|
||||
this.logger.info({offer: req.body, answer: newSdp}, 'SingleDialer:exec: handling reINVITE');
|
||||
}
|
||||
else {
|
||||
this.logger.info('SingleDialer:exec: handling reINVITE with released media, emit event');
|
||||
this.emit('reinvite', req, res);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'Error handling reinvite');
|
||||
}
|
||||
})
|
||||
.on('refer', (req, res) => {
|
||||
this.emit('refer', this.callInfo, req, res);
|
||||
});
|
||||
|
||||
if (this.confirmHook) this._executeApp(this.confirmHook);
|
||||
else this.emit('accept');
|
||||
} catch (err) {
|
||||
this.inviteInProgress = null;
|
||||
const status = {callStatus: CallStatus.Failed};
|
||||
if (err instanceof SipError) {
|
||||
status.sipStatus = err.status;
|
||||
status.sipReason = err.reason;
|
||||
if (err.status === 487) status.callStatus = CallStatus.NoAnswer;
|
||||
else if ([486, 600].includes(err.status)) status.callStatus = CallStatus.Busy;
|
||||
this.logger.info(`SingleDialer:exec outdial failure ${err.status}`);
|
||||
inviteSpan.setAttributes({'invite.status_code': err.status});
|
||||
inviteSpan.end();
|
||||
}
|
||||
else {
|
||||
this.logger.error(err, 'SingleDialer:exec');
|
||||
status.sipStatus = 500;
|
||||
inviteSpan.setAttributes({
|
||||
'invite.status_code': 500,
|
||||
'invite.err': err.message
|
||||
});
|
||||
inviteSpan.end();
|
||||
}
|
||||
this.emit('callStatusChange', status);
|
||||
if (this.ep) this.ep.destroy();
|
||||
@@ -246,8 +311,8 @@ class SingleDialer extends Emitter {
|
||||
async _executeApp(confirmHook) {
|
||||
try {
|
||||
// retrieve set of tasks
|
||||
const tasks = await this.requestor.request(confirmHook, this.callInfo);
|
||||
|
||||
const json = await this.requestor.request('dial:confirm', confirmHook, this.callInfo.toJSON());
|
||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
// verify it contains only allowed verbs
|
||||
const allowedTasks = tasks.filter((task) => {
|
||||
return [
|
||||
@@ -267,7 +332,9 @@ class SingleDialer extends Emitter {
|
||||
dlg: this.dlg,
|
||||
ep: this.ep,
|
||||
callInfo: this.callInfo,
|
||||
tasks
|
||||
accountInfo: this.accountInfo,
|
||||
tasks,
|
||||
rootSpan: this.rootSpan
|
||||
});
|
||||
await cs.exec();
|
||||
|
||||
@@ -281,33 +348,67 @@ class SingleDialer extends Emitter {
|
||||
}
|
||||
|
||||
async doAdulting({logger, tasks, application}) {
|
||||
this.logger = logger;
|
||||
this.adulting = true;
|
||||
this.emit('adulting');
|
||||
await this.ep.unbridge()
|
||||
.catch((err) => this.logger.info({err}, 'SingleDialer:doAdulting - failed to unbridge ep'));
|
||||
this.ep.play('silence_stream://1000');
|
||||
if (this.ep) {
|
||||
await this.ep.unbridge()
|
||||
.catch((err) => this.logger.info({err}, 'SingleDialer:doAdulting - failed to unbridge ep'));
|
||||
this.ep.play('silence_stream://1000');
|
||||
}
|
||||
else {
|
||||
await this.reAnchorMedia();
|
||||
}
|
||||
|
||||
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({
|
||||
logger: this.logger,
|
||||
logger: newLogger,
|
||||
singleDialer: this,
|
||||
application,
|
||||
callInfo: this.callInfo,
|
||||
tasks
|
||||
accountInfo: this.accountInfo,
|
||||
tasks,
|
||||
rootSpan
|
||||
});
|
||||
cs.exec();
|
||||
cs.exec().catch((err) => newLogger.error({err}, 'doAdulting: error executing session'));
|
||||
return cs;
|
||||
}
|
||||
|
||||
_notifyCallStatusChange({callStatus, sipStatus, duration}) {
|
||||
async releaseMediaToSBC(remoteSdp, localSdp) {
|
||||
assert(this.dlg && this.dlg.connected && this.ep && typeof remoteSdp === 'string');
|
||||
const sdp = stripCodecs(this.logger, remoteSdp, localSdp) || remoteSdp;
|
||||
await this.dlg.modify(sdp, {
|
||||
headers: {
|
||||
'X-Reason': 'release-media'
|
||||
}
|
||||
});
|
||||
this.ep.destroy()
|
||||
.then(() => this.ep = null)
|
||||
.catch((err) => this.logger.error({err}, 'SingleDialer:releaseMediaToSBC: Error destroying endpoint'));
|
||||
}
|
||||
|
||||
async reAnchorMedia() {
|
||||
assert(this.dlg && this.dlg.connected && !this.ep);
|
||||
this.ep = await this.ms.createEndpoint({remoteSdp: this.dlg.remote.sdp});
|
||||
await this.dlg.modify(this.ep.local.sdp, {
|
||||
headers: {
|
||||
'X-Reason': 'anchor-media'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_notifyCallStatusChange({callStatus, sipStatus, sipReason, duration}) {
|
||||
assert((typeof duration === 'number' && callStatus === CallStatus.Completed) ||
|
||||
(!duration && callStatus !== CallStatus.Completed),
|
||||
'duration MUST be supplied when call completed AND ONLY when call completed');
|
||||
|
||||
if (this.callInfo) {
|
||||
this.callInfo.updateCallStatus(callStatus, sipStatus);
|
||||
this.callInfo.updateCallStatus(callStatus, sipStatus, sipReason);
|
||||
if (typeof duration === 'number') this.callInfo.duration = duration;
|
||||
try {
|
||||
this.requestor.request(this.application.call_status_hook, this.callInfo.toJSON());
|
||||
this.requestor.request('call:status', this.application.call_status_hook, this.callInfo.toJSON());
|
||||
} catch (err) {
|
||||
this.logger.info(err, `SingleDialer:_notifyCallStatusChange error sending ${callStatus} ${sipStatus}`);
|
||||
}
|
||||
@@ -320,9 +421,13 @@ class SingleDialer extends Emitter {
|
||||
}
|
||||
}
|
||||
|
||||
function placeOutdial({logger, srf, ms, sbcAddress, target, opts, application, callInfo}) {
|
||||
function placeOutdial({
|
||||
logger, srf, ms, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan
|
||||
}) {
|
||||
const myOpts = deepcopy(opts);
|
||||
const sd = new SingleDialer({logger, sbcAddress, target, myOpts, application, callInfo});
|
||||
const sd = new SingleDialer({
|
||||
logger, sbcAddress, target, myOpts, application, callInfo, accountInfo, rootSpan, startSpan
|
||||
});
|
||||
sd.exec(srf, ms, myOpts);
|
||||
return sd;
|
||||
}
|
||||
|
||||
@@ -100,15 +100,15 @@ class Requestor {
|
||||
|
||||
assert.ok(url, 'Requestor:request url was not provided');
|
||||
assert.ok, (['GET', 'POST'].includes(method), `Requestor:request method must be 'GET' or 'POST' not ${method}`);
|
||||
|
||||
this.logger.debug({hook, payload}, `Requestor:request ${method} ${url}`);
|
||||
const {url: urlInfo = hook, method: methodInfo = 'POST'} = hook; // mask user/pass
|
||||
this.logger.debug({url: urlInfo, method: methodInfo, payload}, `Requestor:request ${method} ${url}`);
|
||||
const startAt = process.hrtime();
|
||||
|
||||
let buf;
|
||||
try {
|
||||
const sigHeader = generateSigHeader(payload, this.secret);
|
||||
const headers = {...sigHeader, ...this.authHeader};
|
||||
this.logger.info({url, headers}, 'send webhook');
|
||||
//this.logger.info({url, headers}, 'send webhook');
|
||||
buf = isRelativeUrl(url) ?
|
||||
await this.post(url, payload, headers) :
|
||||
await bent(method, 'buffer', 200, 201, 202)(url, payload, headers);
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
const assert = require('assert');
|
||||
const noopLogger = {info: () => {}, error: () => {}};
|
||||
const {LifeCycleEvents} = require('./constants');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const {LifeCycleEvents, FS_UUID_SET_NAME} = require('./constants');
|
||||
const Emitter = require('events');
|
||||
const debug = require('debug')('jambonz:feature-server');
|
||||
const noopLogger = {info: () => {}, error: () => {}};
|
||||
|
||||
module.exports = (logger) => {
|
||||
logger = logger || noopLogger;
|
||||
let idxSbc = 0;
|
||||
let sbcs = [];
|
||||
|
||||
assert.ok(process.env.JAMBONES_SBCS, 'missing JAMBONES_SBCS env var');
|
||||
const sbcs = process.env.JAMBONES_SBCS
|
||||
.split(',')
|
||||
.map((sbc) => sbc.trim());
|
||||
assert.ok(sbcs.length, 'JAMBONES_SBCS env var is empty or misconfigured');
|
||||
logger.info({sbcs}, 'SBC inventory');
|
||||
if (process.env.JAMBONES_SBCS) {
|
||||
sbcs = process.env.JAMBONES_SBCS
|
||||
.split(',')
|
||||
.map((sbc) => sbc.trim());
|
||||
assert.ok(sbcs.length, 'JAMBONES_SBCS env var is empty or misconfigured');
|
||||
logger.info({sbcs}, 'SBC inventory');
|
||||
}
|
||||
else if (process.env.K8S && process.env.K8S_SBC_SIP_SERVICE_NAME) {
|
||||
sbcs = [`${process.env.K8S_SBC_SIP_SERVICE_NAME}:5060`];
|
||||
logger.info({sbcs}, 'SBC inventory');
|
||||
}
|
||||
|
||||
// listen for SNS lifecycle changes
|
||||
let lifecycleEmitter = new Emitter();
|
||||
@@ -25,6 +32,10 @@ module.exports = (logger) => {
|
||||
lifecycleEmitter = await require('./aws-sns-lifecycle')(logger);
|
||||
|
||||
lifecycleEmitter
|
||||
.on('SubscriptionConfirmation', ({publicIp}) => {
|
||||
const {srf} = require('../..');
|
||||
srf.locals.publicIp = publicIp;
|
||||
})
|
||||
.on(LifeCycleEvents.ScaleIn, () => {
|
||||
logger.info('AWS scale-in notification: begin drying up calls');
|
||||
dryUpCalls = true;
|
||||
@@ -65,7 +76,6 @@ module.exports = (logger) => {
|
||||
})();
|
||||
}
|
||||
|
||||
// send OPTIONS pings to SBCs
|
||||
async function pingProxies(srf) {
|
||||
if (process.env.NODE_ENV === 'test') return;
|
||||
|
||||
@@ -88,18 +98,40 @@ module.exports = (logger) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (process.env.K8S) {
|
||||
setImmediate(() => {
|
||||
logger.info('disabling OPTIONS pings since we are running as a kubernetes service');
|
||||
const {srf} = require('../..');
|
||||
const {addToSet} = srf.locals.dbHelpers;
|
||||
const uuid = srf.locals.fsUUID = uuidv4();
|
||||
addToSet(FS_UUID_SET_NAME, uuid)
|
||||
.catch((err) => logger.info({err}, `Error adding ${uuid} to set ${FS_UUID_SET_NAME}`));
|
||||
});
|
||||
}
|
||||
else {
|
||||
// OPTIONS ping the SBCs from each feature server every 60 seconds
|
||||
setInterval(() => {
|
||||
const {srf} = require('../..');
|
||||
pingProxies(srf);
|
||||
}, process.env.OPTIONS_PING_INTERVAL || 30000);
|
||||
|
||||
// OPTIONS ping the SBCs from each feature server every 60 seconds
|
||||
setInterval(() => {
|
||||
const {srf} = require('../..');
|
||||
pingProxies(srf);
|
||||
}, 20000);
|
||||
// initial ping once we are up
|
||||
setTimeout(async() => {
|
||||
|
||||
// initial ping once we are up
|
||||
setTimeout(() => {
|
||||
const {srf} = require('../..');
|
||||
pingProxies(srf);
|
||||
}, 1000);
|
||||
// if SBCs are auto-scaling, monitor them as they come and go
|
||||
const {srf} = require('../..');
|
||||
if (!process.env.JAMBONES_SBCS) {
|
||||
const {monitorSet} = srf.locals.dbHelpers;
|
||||
const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-sip`;
|
||||
await monitorSet(setName, 10, (members) => {
|
||||
sbcs = members;
|
||||
logger.info(`sbc-pinger: SBC roster has changed, list of active SBCs is now ${sbcs}`);
|
||||
});
|
||||
}
|
||||
|
||||
pingProxies(srf);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return {
|
||||
lifecycleEmitter,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
const CIDRMatcher = require('cidr-matcher');
|
||||
const matcher = new CIDRMatcher([process.env.JAMBONES_NETWORK_CIDR]);
|
||||
|
||||
module.exports = (sbcList) => {
|
||||
const obj = sbcList
|
||||
.split(',')
|
||||
.map((str) => {
|
||||
const arr = /^(.*)\/(.*):(\d+)$/.exec(str);
|
||||
return {protocol: arr[1], host: arr[2], port: arr[3]};
|
||||
})
|
||||
.find((obj) => 'udp' == obj.protocol && matcher.contains(obj.host));
|
||||
if (obj) return `${obj.host}:${obj.port}`;
|
||||
};
|
||||
30
lib/utils/strip-ancillary-codecs.js
Normal file
30
lib/utils/strip-ancillary-codecs.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const sdpTransform = require('sdp-transform');
|
||||
|
||||
const stripCodecs = (logger, remoteSdp, localSdp) => {
|
||||
try {
|
||||
const sdp = sdpTransform.parse(remoteSdp);
|
||||
const local = sdpTransform.parse(localSdp);
|
||||
const m = local.media
|
||||
.find((m) => 'audio' === m.type);
|
||||
const pt = m.rtp[0].payload;
|
||||
|
||||
/* manipulate on the audio section */
|
||||
const audio = sdp.media.find((m) => 'audio' === m.type);
|
||||
|
||||
/* discard all of the codecs except the first in our 200 OK, and telephony-events */
|
||||
const ptSaves = audio.rtp
|
||||
.filter((r) => r.codec === 'telephone-event' || r.payload === pt)
|
||||
.map((r) => r.payload);
|
||||
const rtp = audio.rtp.filter((r) => ptSaves.includes(r.payload));
|
||||
|
||||
/* reattach the new rtp sections and stripped payload list */
|
||||
audio.rtp = rtp;
|
||||
audio.payloads = rtp.map((r) => r.payload).join(' ');
|
||||
return sdpTransform.write(sdp);
|
||||
} catch (err) {
|
||||
logger.error({err, remoteSdp, localSdp}, 'strip-ancillary-codecs error');
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = stripCodecs;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
module.exports = function(tasks) {
|
||||
return `[${tasks.map((t) => t.name).join(',')}]`;
|
||||
return `[${tasks.map((t) => t.summary).join(',')}]`;
|
||||
};
|
||||
|
||||
331
lib/utils/ws-requestor.js
Normal file
331
lib/utils/ws-requestor.js
Normal file
@@ -0,0 +1,331 @@
|
||||
const assert = require('assert');
|
||||
const BaseRequestor = require('./base-requestor');
|
||||
const short = require('short-uuid');
|
||||
const {HookMsgTypes} = require('./constants.json');
|
||||
const Websocket = require('ws');
|
||||
const snakeCaseKeys = require('./snakecase-keys');
|
||||
const HttpRequestor = require('./http-requestor');
|
||||
const MAX_RECONNECTS = 5;
|
||||
const RESPONSE_TIMEOUT_MS = process.env.JAMBONES_WS_API_MSG_RESPONSE_TIMEOUT || 5000;
|
||||
|
||||
class WsRequestor extends BaseRequestor {
|
||||
constructor(logger, account_sid, hook, secret) {
|
||||
super(logger, account_sid, hook, secret);
|
||||
this.connections = 0;
|
||||
this.messagesInFlight = new Map();
|
||||
this.maliciousClient = false;
|
||||
this.closedGracefully = false;
|
||||
this.backoffMs = 500;
|
||||
this.connectInProgress = false;
|
||||
this.queuedMsg = [];
|
||||
this.id = short.generate();
|
||||
|
||||
assert(this._isAbsoluteUrl(this.url));
|
||||
|
||||
this.on('socket-closed', this._onSocketClosed.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON payload over the websocket. If this is the first request,
|
||||
* open the websocket.
|
||||
* All requests expect an ack message in response
|
||||
* @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(type, hook, params, httpHeaders = {}) {
|
||||
assert(HookMsgTypes.includes(type));
|
||||
const url = hook.url || hook;
|
||||
|
||||
if (this.maliciousClient) {
|
||||
this.logger.info({url: this.url}, 'WsRequestor:request - discarding msg to malicious client');
|
||||
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 (this._isAbsoluteUrl(url) && url.startsWith('http')) {
|
||||
this.logger.debug({hook}, 'WsRequestor: sending a webhook (HTTP)');
|
||||
const requestor = new HttpRequestor(this.logger, this.account_sid, hook, this.secret);
|
||||
return requestor.request(type, hook, params, httpHeaders);
|
||||
}
|
||||
|
||||
/* connect if necessary */
|
||||
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) {
|
||||
throw new Error(`max attempts connecting to ${this.url}`);
|
||||
}
|
||||
try {
|
||||
const startAt = process.hrtime();
|
||||
await this._connect();
|
||||
const rtt = this._roundTrip(startAt);
|
||||
this.stats.histogram('app.hook.connect_time', rtt, ['hook_type:app']);
|
||||
} catch (err) {
|
||||
this.logger.info({url, err}, 'WsRequestor:request - failed connecting');
|
||||
this.connectInProgress = false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
assert(this.ws);
|
||||
|
||||
/* prepare and send message */
|
||||
let 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');
|
||||
|
||||
const msgid = short.generate();
|
||||
const b3 = httpHeaders?.b3 ? {b3: httpHeaders.b3} : {};
|
||||
const obj = {
|
||||
type,
|
||||
msgid,
|
||||
call_sid: this.call_sid,
|
||||
hook: type === 'verb:hook' ? url : undefined,
|
||||
data: {...payload},
|
||||
...b3
|
||||
};
|
||||
|
||||
const sendQueuedMsgs = () => {
|
||||
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 */
|
||||
if (['call:status', 'jambonz:error', 'session:reconnect'].includes(type)) {
|
||||
this.ws.send(JSON.stringify(obj), () => {
|
||||
this.logger.debug({obj}, `WsRequestor:request websocket: sent (${url})`);
|
||||
sendQueuedMsgs();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
/* messages that require an ack */
|
||||
return new Promise((resolve, reject) => {
|
||||
/* give the far end a reasonable amount of time to ack our message */
|
||||
const timer = setTimeout(() => {
|
||||
const {failure} = this.messagesInFlight.get(msgid);
|
||||
failure && failure(`timeout from far end for msgid ${msgid}`);
|
||||
this.messagesInFlight.delete(msgid);
|
||||
}, RESPONSE_TIMEOUT_MS);
|
||||
|
||||
/* save the message info for reply */
|
||||
const startAt = process.hrtime();
|
||||
this.messagesInFlight.set(msgid, {
|
||||
timer,
|
||||
success: (response) => {
|
||||
clearTimeout(timer);
|
||||
const rtt = this._roundTrip(startAt);
|
||||
this.logger.info({response}, `WsRequestor:request ${url} succeeded in ${rtt}ms`);
|
||||
this.stats.histogram('app.hook.ws_response_time', rtt, ['hook_type:app']);
|
||||
resolve(response);
|
||||
},
|
||||
failure: (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
/* send the message */
|
||||
this.ws.send(JSON.stringify(obj), () => {
|
||||
this.logger.debug({obj}, `WsRequestor:request websocket: sent (${url})`);
|
||||
sendQueuedMsgs();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closedGracefully = true;
|
||||
this.logger.info('WsRequestor:close closing socket');
|
||||
try {
|
||||
if (this.ws) {
|
||||
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() {
|
||||
assert(!this.ws);
|
||||
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 = {
|
||||
followRedirects: true,
|
||||
maxRedirects: 2,
|
||||
handshakeTimeout,
|
||||
maxPayload: 8096,
|
||||
};
|
||||
if (this.username && this.password) opts = {...opts, auth: `${this.username}:${this.password}`};
|
||||
|
||||
this
|
||||
.once('ready', (ws) => {
|
||||
this.removeAllListeners('not-ready');
|
||||
if (this.connections > 1) this.request('session:reconnect', this.url);
|
||||
resolve();
|
||||
})
|
||||
.once('not-ready', (err) => {
|
||||
this.removeAllListeners('ready');
|
||||
reject(err);
|
||||
});
|
||||
const ws = new Websocket(this.url, ['ws.jambonz.org'], opts);
|
||||
this._setHandlers(ws);
|
||||
});
|
||||
}
|
||||
|
||||
_setHandlers(ws) {
|
||||
this.logger.debug('WsRequestor:_setHandlers');
|
||||
ws
|
||||
.once('open', this._onOpen.bind(this, ws))
|
||||
.once('close', this._onClose.bind(this))
|
||||
.on('message', this._onMessage.bind(this))
|
||||
.once('unexpected-response', this._onUnexpectedResponse.bind(this, ws))
|
||||
.on('error', this._onError.bind(this));
|
||||
}
|
||||
|
||||
_onError(err) {
|
||||
if (this.connections > 0) {
|
||||
this.logger.info({url: this.url, err}, 'WsRequestor:_onError');
|
||||
}
|
||||
else this.emit('not-ready', err);
|
||||
}
|
||||
|
||||
_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);
|
||||
this.ws = ws;
|
||||
this.connectInProgress = false;
|
||||
this.connections++;
|
||||
this.emit('ready', ws);
|
||||
}
|
||||
|
||||
_onClose(code) {
|
||||
this.logger.info(`WsRequestor(${this.id}) - closed from far end ${code}`);
|
||||
if (this.connections > 0 && code !== 1000) {
|
||||
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) {
|
||||
assert(!this.ws);
|
||||
this.logger.info({
|
||||
headers: res.headers,
|
||||
statusCode: res.statusCode,
|
||||
statusMessage: res.statusMessage
|
||||
}, 'WsRequestor - unexpected response');
|
||||
this.emit('connection-failure');
|
||||
this.emit('not-ready', new Error(`${res.statusCode} ${res.statusMessage}`));
|
||||
}
|
||||
|
||||
_onSocketClosed() {
|
||||
this.ws = null;
|
||||
this.emit('connection-dropped');
|
||||
if (this.connections > 0 && this.connections < MAX_RECONNECTS && !this.closedGracefully) {
|
||||
this.logger.debug(`WsRequestor:_onSocketClosed waiting ${this.backoffMs} to reconnect`);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
_onMessage(content, isBinary) {
|
||||
if (this.isBinary) {
|
||||
this.logger.info({url: this.url}, 'WsRequestor:_onMessage - discarding binary message');
|
||||
this.maliciousClient = true;
|
||||
this.ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
/* messages must be JSON format */
|
||||
try {
|
||||
const obj = 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');
|
||||
|
||||
switch (type) {
|
||||
case 'ack':
|
||||
assert.ok(msgid, 'msgid not supplied');
|
||||
this._recvAck(msgid, data);
|
||||
break;
|
||||
|
||||
case 'command':
|
||||
assert.ok(command, 'command property not supplied');
|
||||
assert.ok(data, 'data property not supplied');
|
||||
this._recvCommand(msgid, command, call_sid, queueCommand, data);
|
||||
break;
|
||||
|
||||
default:
|
||||
assert.ok(false, `invalid type property: ${type}`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err, content}, 'WsRequestor:_onMessage - invalid incoming message');
|
||||
}
|
||||
}
|
||||
|
||||
_recvAck(msgid, data) {
|
||||
const obj = this.messagesInFlight.get(msgid);
|
||||
if (!obj) {
|
||||
this.logger.info({url: this.url}, `WsRequestor:_recvAck - ack to unknown msgid ${msgid}, discarding`);
|
||||
return;
|
||||
}
|
||||
this.logger.debug({url: this.url}, `WsRequestor:_recvAck - received response to ${msgid}`);
|
||||
this.messagesInFlight.delete(msgid);
|
||||
const {success} = obj;
|
||||
success && success(data);
|
||||
}
|
||||
|
||||
_recvCommand(msgid, command, call_sid, queueCommand, data) {
|
||||
// TODO: validate command
|
||||
this.logger.info({msgid, command, call_sid, queueCommand, data}, 'received command');
|
||||
this.emit('command', {msgid, command, call_sid, queueCommand, data});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WsRequestor;
|
||||
5764
package-lock.json
generated
5764
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
57
package.json
57
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jambonz-feature-server",
|
||||
"version": "0.3.1",
|
||||
"version": "v0.7.5",
|
||||
"main": "app.js",
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
@@ -21,39 +21,54 @@
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node app",
|
||||
"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/ ",
|
||||
"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=info 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",
|
||||
"jslint": "eslint app.js lib"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jambonz/db-helpers": "^0.6.13",
|
||||
"@jambonz/mw-registrar": "^0.2.1",
|
||||
"@jambonz/realtimedb-helpers": "^0.4.1",
|
||||
"@jambonz/stats-collector": "^0.1.5",
|
||||
"@jambonz/time-series": "^0.1.5",
|
||||
"aws-sdk": "^2.846.0",
|
||||
"@jambonz/db-helpers": "^0.6.18",
|
||||
"@jambonz/http-health-check": "^0.0.1",
|
||||
"@jambonz/realtimedb-helpers": "^0.4.29",
|
||||
"@jambonz/stats-collector": "^0.1.6",
|
||||
"@jambonz/time-series": "^0.1.9",
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
"@opentelemetry/exporter-jaeger": "^1.3.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.27.0",
|
||||
"@opentelemetry/exporter-zipkin": "^1.3.1",
|
||||
"@opentelemetry/instrumentation": "^0.27.0",
|
||||
"@opentelemetry/resources": "^1.3.1",
|
||||
"@opentelemetry/sdk-trace-base": "^1.3.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.3.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.3.1",
|
||||
"aws-sdk": "^2.1152.0",
|
||||
"bent": "^7.3.12",
|
||||
"cidr-matcher": "^2.1.1",
|
||||
"debug": "^4.3.1",
|
||||
"debug": "^4.3.4",
|
||||
"deepcopy": "^2.1.0",
|
||||
"drachtio-fsmrf": "^2.0.7",
|
||||
"drachtio-srf": "^4.4.50",
|
||||
"express": "^4.17.1",
|
||||
"ip": "^1.1.5",
|
||||
"moment": "^2.29.1",
|
||||
"parse-url": "^5.0.2",
|
||||
"pino": "^6.11.2",
|
||||
"drachtio-fsmrf": "^3.0.1",
|
||||
"drachtio-srf": "^4.5.1",
|
||||
"express": "^4.18.1",
|
||||
"helmet": "^5.1.0",
|
||||
"ip": "^1.1.8",
|
||||
"moment": "^2.29.3",
|
||||
"parse-url": "^5.0.8",
|
||||
"pino": "^6.14.0",
|
||||
"sdp-transform": "^2.14.1",
|
||||
"short-uuid": "^4.2.0",
|
||||
"to-snake-case": "^1.0.0",
|
||||
"uuid": "^8.3.2",
|
||||
"verify-aws-sns-signature": "^0.0.6",
|
||||
"ws": "^8.8.0",
|
||||
"xml2js": "^0.4.23"
|
||||
},
|
||||
"devDependencies": {
|
||||
"async": "^3.2.0",
|
||||
"clear-module": "^4.1.1",
|
||||
"eslint": "^7.20.0",
|
||||
"clear-module": "^4.1.2",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-plugin-promise": "^4.3.1",
|
||||
"nyc": "^15.1.0",
|
||||
"tape": "^5.2.2"
|
||||
"tape": "^5.5.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.0.6",
|
||||
"utf-8-validate": "^5.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +251,7 @@ 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 ('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 ('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 */;
|
||||
UNLOCK TABLES;
|
||||
|
||||
@@ -449,6 +450,7 @@ 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 ('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 ('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 */;
|
||||
UNLOCK TABLES;
|
||||
|
||||
@@ -587,7 +589,8 @@ DROP TABLE IF EXISTS `speech_credentials`;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `speech_credentials` (
|
||||
`speech_credential_sid` char(36) NOT NULL,
|
||||
`account_sid` char(36) NOT NULL,
|
||||
`service_provider_sid` CHAR(36),
|
||||
`account_sid` char(36) NOT NULL,
|
||||
`vendor` varchar(255) NOT NULL,
|
||||
`credential` VARCHAR(8192) NOT NULL,
|
||||
`use_for_tts` tinyint(1) DEFAULT '1',
|
||||
@@ -611,7 +614,7 @@ CREATE TABLE `speech_credentials` (
|
||||
|
||||
LOCK TABLES `speech_credentials` WRITE;
|
||||
/*!40000 ALTER TABLE `speech_credentials` DISABLE KEYS */;
|
||||
INSERT INTO `speech_credentials` VALUES ('2add163c-34f2-45c6-a016-f955d218ffb6','bb845d4b-83a9-4cde-a6e9-50f3743bab3f','google','credential-goes-here',1,1,NULL,'2021-04-03 15:42:10',1,1),('84154212-5c99-4c94-8993-bc2a46288daa','bb845d4b-83a9-4cde-a6e9-50f3743bab3f','aws','credential-goes-here',0,0,NULL,NULL,NULL,NULL);
|
||||
INSERT INTO `speech_credentials` VALUES ('2add163c-34f2-45c6-a016-f955d218ffb6',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','google','credential-goes-here',1,1,NULL,'2021-04-03 15:42:10',1,1),('84154212-5c99-4c94-8993-bc2a46288daa',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','aws','credential-goes-here',0,0,NULL,NULL,NULL,NULL);
|
||||
/*!40000 ALTER TABLE `speech_credentials` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
|
||||
@@ -735,6 +738,7 @@ 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 ('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 ('c9113e7a-741f-48b9-96c1-f2f78176eeb3','http://127.0.0.1:3104/','POST',NULL,NULL);
|
||||
/*!40000 ALTER TABLE `webhooks` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
|
||||
|
||||
@@ -20,12 +20,16 @@ DROP TABLE IF EXISTS lcr_routes;
|
||||
|
||||
DROP TABLE IF EXISTS predefined_sip_gateways;
|
||||
|
||||
DROP TABLE IF EXISTS predefined_smpp_gateways;
|
||||
|
||||
DROP TABLE IF EXISTS predefined_carriers;
|
||||
|
||||
DROP TABLE IF EXISTS account_offers;
|
||||
|
||||
DROP TABLE IF EXISTS products;
|
||||
|
||||
DROP TABLE IF EXISTS schema_version;
|
||||
|
||||
DROP TABLE IF EXISTS api_keys;
|
||||
|
||||
DROP TABLE IF EXISTS sbc_addresses;
|
||||
@@ -148,6 +152,20 @@ predefined_carrier_sid CHAR(36) NOT NULL,
|
||||
PRIMARY KEY (predefined_sip_gateway_sid)
|
||||
);
|
||||
|
||||
CREATE TABLE predefined_smpp_gateways
|
||||
(
|
||||
predefined_smpp_gateway_sid CHAR(36) NOT NULL UNIQUE ,
|
||||
ipv4 VARCHAR(128) NOT NULL COMMENT 'ip address or DNS name of the gateway. ',
|
||||
port INTEGER NOT NULL DEFAULT 2775 COMMENT 'smpp signaling port',
|
||||
inbound BOOLEAN NOT NULL COMMENT 'if true, whitelist this IP to allow inbound SMS from the gateway',
|
||||
outbound BOOLEAN NOT NULL COMMENT 'i',
|
||||
netmask INTEGER NOT NULL DEFAULT 32,
|
||||
is_primary BOOLEAN NOT NULL DEFAULT 1,
|
||||
use_tls BOOLEAN DEFAULT 0,
|
||||
predefined_carrier_sid CHAR(36) NOT NULL,
|
||||
PRIMARY KEY (predefined_smpp_gateway_sid)
|
||||
);
|
||||
|
||||
CREATE TABLE products
|
||||
(
|
||||
product_sid CHAR(36) NOT NULL UNIQUE ,
|
||||
@@ -174,6 +192,11 @@ stripe_product_id VARCHAR(56) NOT NULL,
|
||||
PRIMARY KEY (account_offer_sid)
|
||||
);
|
||||
|
||||
CREATE TABLE schema_version
|
||||
(
|
||||
version VARCHAR(16)
|
||||
);
|
||||
|
||||
CREATE TABLE api_keys
|
||||
(
|
||||
api_key_sid CHAR(36) NOT NULL UNIQUE ,
|
||||
@@ -420,6 +443,10 @@ CREATE INDEX predefined_sip_gateway_sid_idx ON predefined_sip_gateways (predefin
|
||||
CREATE INDEX predefined_carrier_sid_idx ON predefined_sip_gateways (predefined_carrier_sid);
|
||||
ALTER TABLE predefined_sip_gateways ADD FOREIGN KEY predefined_carrier_sid_idxfk (predefined_carrier_sid) REFERENCES predefined_carriers (predefined_carrier_sid);
|
||||
|
||||
CREATE INDEX predefined_smpp_gateway_sid_idx ON predefined_smpp_gateways (predefined_smpp_gateway_sid);
|
||||
CREATE INDEX predefined_carrier_sid_idx ON predefined_smpp_gateways (predefined_carrier_sid);
|
||||
ALTER TABLE predefined_smpp_gateways ADD FOREIGN KEY predefined_carrier_sid_idxfk_1 (predefined_carrier_sid) REFERENCES predefined_carriers (predefined_carrier_sid);
|
||||
|
||||
CREATE INDEX product_sid_idx ON products (product_sid);
|
||||
CREATE INDEX account_product_sid_idx ON account_products (account_product_sid);
|
||||
CREATE INDEX account_subscription_sid_idx ON account_products (account_subscription_sid);
|
||||
@@ -545,4 +572,4 @@ ALTER TABLE accounts ADD FOREIGN KEY queue_event_hook_sid_idxfk (queue_event_hoo
|
||||
|
||||
ALTER TABLE accounts ADD FOREIGN KEY device_calling_application_sid_idxfk (device_calling_application_sid) REFERENCES applications (application_sid);
|
||||
|
||||
SET FOREIGN_KEY_CHECKS=1;
|
||||
SET FOREIGN_KEY_CHECKS=0;
|
||||
|
||||
@@ -9,6 +9,7 @@ networks:
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:5.7
|
||||
platform: linux/x86_64
|
||||
ports:
|
||||
- "3360:3306"
|
||||
environment:
|
||||
@@ -122,8 +123,20 @@ services:
|
||||
fs:
|
||||
ipv4_address: 172.38.0.63
|
||||
|
||||
webhook-sip-info:
|
||||
image: jambonz/webhook-test-scaffold:latest
|
||||
environment:
|
||||
APP_PATH: /tmp/info.json
|
||||
ports:
|
||||
- "3104:3000/tcp"
|
||||
volumes:
|
||||
- ./test-apps:/tmp
|
||||
networks:
|
||||
fs:
|
||||
ipv4_address: 172.38.0.64
|
||||
|
||||
influxdb:
|
||||
image: influxdb:1.8-alpine
|
||||
image: influxdb:1.8
|
||||
ports:
|
||||
- "8086:8086"
|
||||
networks:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const test = require('tape') ;
|
||||
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) => {
|
||||
exec(`docker-compose -f ${__dirname}/docker-compose-testbed.yaml up -d`, (err, stdout, stderr) => {
|
||||
|
||||
@@ -5,5 +5,6 @@ require('./account-validation-tests');
|
||||
require('./webhooks-tests');
|
||||
require('./say-tests');
|
||||
require('./gather-tests');
|
||||
require('./sip-request-tests');
|
||||
require('./remove-test-db');
|
||||
require('./docker_stop');
|
||||
|
||||
107
test/scenarios/uac-send-info-during-dialog.xml
Normal file
107
test/scenarios/uac-send-info-during-dialog.xml
Normal file
@@ -0,0 +1,107 @@
|
||||
<?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:16174000006@[remote_ip]:[remote_port] SIP/2.0
|
||||
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
||||
From: sipp <sip:sipp@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: <sip:16174000006@[remote_ip]:[remote_port]>
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 INVITE
|
||||
Contact: sip:sipp@[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:16174000006@[remote_ip]:[remote_port] SIP/2.0
|
||||
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
||||
From: sipp <sip:sipp@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: 16174000006 <sip:16174000006@[remote_ip]:[remote_port]>[peer_tag_param]
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 ACK
|
||||
Contact: sip:sipp@[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>
|
||||
35
test/sip-request-tests.js
Normal file
35
test/sip-request-tests.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const test = require('tape');
|
||||
const { sippUac } = require('./sipp')('test_fs');
|
||||
const bent = require('bent');
|
||||
const getJSON = bent('json')
|
||||
const clearModule = require('clear-module');
|
||||
|
||||
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);
|
||||
await sippUac('uac-send-info-during-dialog.xml', '172.38.0.10');
|
||||
const obj = await getJSON('http://127.0.0.1:3104/actionHook');
|
||||
t.ok(obj.result === 'success' && obj.sip_status === 200, 'successfully sent SIP INFO');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
const test = require('blue-tape');
|
||||
const { output, sippUac } = require('./sipp')('test_sbc-inbound');
|
||||
const debug = require('debug')('drachtio:sbc-inbound');
|
||||
const clearModule = require('clear-module');
|
||||
const test = require('tape');
|
||||
const { sippUac } = require('./sipp')('test_sbc-inbound');
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
|
||||
15
test/test-apps/info.json
Normal file
15
test/test-apps/info.json
Normal file
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"verb": "say",
|
||||
"text": "hello"
|
||||
},
|
||||
{
|
||||
"verb": "sip:request",
|
||||
"method": "info",
|
||||
"headers": {
|
||||
"Content-Type": "application/text"
|
||||
},
|
||||
"body": "here I am ",
|
||||
"actionHook": "/actionHook"
|
||||
}
|
||||
]
|
||||
61
tracer.js
Normal file
61
tracer.js
Normal file
@@ -0,0 +1,61 @@
|
||||
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) {
|
||||
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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user