mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2026-01-25 02:07:56 +00:00
Compare commits
227 Commits
v0.7.6-rc2
...
feat/queue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7393e3bcb7 | ||
|
|
32a84b7b19 | ||
|
|
6933e82d46 | ||
|
|
fb1801ce11 | ||
|
|
09abb23968 | ||
|
|
eb1e0d3bf5 | ||
|
|
3b6c103618 | ||
|
|
feccc0fca7 | ||
|
|
51bcb5a2d2 | ||
|
|
7a184a8bbc | ||
|
|
5043edfd4e | ||
|
|
9948592080 | ||
|
|
6dc019e836 | ||
|
|
a22bc8ea42 | ||
|
|
0356b996ba | ||
|
|
271587617e | ||
|
|
0b29e67a0c | ||
|
|
e656d275fe | ||
|
|
fabf01f8b5 | ||
|
|
85ab75d8e3 | ||
|
|
5c2630fe1f | ||
|
|
9942313ea1 | ||
|
|
e67cb18b6d | ||
|
|
86df53f8c4 | ||
|
|
5d50f68725 | ||
|
|
f22b236dfc | ||
|
|
2862c827e0 | ||
|
|
266980d770 | ||
|
|
04003a709e | ||
|
|
565ee609ef | ||
|
|
9587465e85 | ||
|
|
845d80a23d | ||
|
|
3109db7861 | ||
|
|
11c5047465 | ||
|
|
e19ea629f0 | ||
|
|
fe529c6bfb | ||
|
|
e980b82ec4 | ||
|
|
318ca19791 | ||
|
|
e2bd211346 | ||
|
|
410c07fae6 | ||
|
|
2ebfbfb3d8 | ||
|
|
a29795839d | ||
|
|
28088a4cdd | ||
|
|
afb381eec9 | ||
|
|
ed00ccb681 | ||
|
|
6e945dde9a | ||
|
|
efdea3e514 | ||
|
|
5131d524ce | ||
|
|
c0114015ea | ||
|
|
a293ec09d0 | ||
|
|
f71ae83ce4 | ||
|
|
0dd161913c | ||
|
|
63ab554908 | ||
|
|
e1bd075ebc | ||
|
|
9de89258a1 | ||
|
|
145ed488db | ||
|
|
c06a43adfa | ||
|
|
bebc82d194 | ||
|
|
cdc82e99ff | ||
|
|
dd4d9aa261 | ||
|
|
1dcf9ee5a2 | ||
|
|
4b28db0946 | ||
|
|
e7ff76b938 | ||
|
|
f245275983 | ||
|
|
690deed89d | ||
|
|
26053ec709 | ||
|
|
34e8203338 | ||
|
|
7be3c64116 | ||
|
|
f71d3aed8b | ||
|
|
5ab24337b2 | ||
|
|
2af76d94a6 | ||
|
|
4919c05181 | ||
|
|
3084a9d6ba | ||
|
|
1c683f1142 | ||
|
|
ab1947e23e | ||
|
|
5527abff09 | ||
|
|
68827112fc | ||
|
|
8a9a2df128 | ||
|
|
3a3544a5e8 | ||
|
|
cbeb706946 | ||
|
|
f005262615 | ||
|
|
67ec28484c | ||
|
|
803a944240 | ||
|
|
a5cd342e46 | ||
|
|
e91feb64f5 | ||
|
|
ae688ddc7e | ||
|
|
9b21b65478 | ||
|
|
c09425fa89 | ||
|
|
6706992b4b | ||
|
|
0fdcb3a6d6 | ||
|
|
50057deca9 | ||
|
|
c7eacdd0f8 | ||
|
|
e990b5dbf9 | ||
|
|
7ae37b1e60 | ||
|
|
ed284c367d | ||
|
|
272380dc62 | ||
|
|
61dbb659b3 | ||
|
|
fbae7c0eab | ||
|
|
4894a85569 | ||
|
|
0e5bb876ce | ||
|
|
8658d03f1f | ||
|
|
f2ff5250b0 | ||
|
|
c37fba541f | ||
|
|
f9921cf4e9 | ||
|
|
86fed4ec90 | ||
|
|
9d07a1354c | ||
|
|
2775c7ddd1 | ||
|
|
70822cb278 | ||
|
|
14a02735be | ||
|
|
4b3ebe37ac | ||
|
|
f4fbd07f8e | ||
|
|
6ebba8673f | ||
|
|
2b06177dc5 | ||
|
|
088316d266 | ||
|
|
8c0044a378 | ||
|
|
dae307d71f | ||
|
|
1b5b37184b | ||
|
|
2f8efb80d0 | ||
|
|
c57e88b496 | ||
|
|
7122d955fe | ||
|
|
028aeea856 | ||
|
|
567b03fd36 | ||
|
|
d5c04d2133 | ||
|
|
a2e909b057 | ||
|
|
c3627cecb8 | ||
|
|
6753fdc2b4 | ||
|
|
740d996739 | ||
|
|
714d06a600 | ||
|
|
0c52324915 | ||
|
|
2e3fb60e72 | ||
|
|
05a4665f87 | ||
|
|
b16d49d8ea | ||
|
|
aad2d52efd | ||
|
|
83d767116b | ||
|
|
b4673ad942 | ||
|
|
9b8bb07a97 | ||
|
|
29f578ff5c | ||
|
|
6d86793494 | ||
|
|
9f95fde67e | ||
|
|
010b4d2778 | ||
|
|
8d81c20c1a | ||
|
|
69f796e960 | ||
|
|
4db03d3d1b | ||
|
|
a60c6a4740 | ||
|
|
5b875c3ad4 | ||
|
|
bf19d2ae6d | ||
|
|
37efdc62be | ||
|
|
78a76bb1f4 | ||
|
|
39fb762a15 | ||
|
|
2cc3140de0 | ||
|
|
1a1f2770b6 | ||
|
|
23f3b44b8b | ||
|
|
753d46e513 | ||
|
|
71a2435c63 | ||
|
|
8686348454 | ||
|
|
f511e6ab6b | ||
|
|
706cd4b94b | ||
|
|
e5c209e269 | ||
|
|
d903dbe28d | ||
|
|
d88321c24d | ||
|
|
6e1761bab6 | ||
|
|
509bb065bb | ||
|
|
203b9774ca | ||
|
|
fade47d423 | ||
|
|
26e52d131e | ||
|
|
70caf00dd1 | ||
|
|
f044cdd150 | ||
|
|
c3d39f0970 | ||
|
|
9c69a2c79f | ||
|
|
e0607b9c2e | ||
|
|
dc378cd065 | ||
|
|
138950c534 | ||
|
|
215a28b615 | ||
|
|
3a5efa37b9 | ||
|
|
917b8f332c | ||
|
|
17848ea22c | ||
|
|
43af27e802 | ||
|
|
b25f92e17a | ||
|
|
90cb5e1348 | ||
|
|
cf821569b3 | ||
|
|
218f2d6c67 | ||
|
|
c2c8f00978 | ||
|
|
32714d73f3 | ||
|
|
8da85ebd5a | ||
|
|
dcedf68264 | ||
|
|
05c5d2211f | ||
|
|
0c089e2380 | ||
|
|
099f33857c | ||
|
|
bd49dacac4 | ||
|
|
876824abde | ||
|
|
468a9e6d6b | ||
|
|
c88163fe11 | ||
|
|
bf7ece8f17 | ||
|
|
e90ef6bc70 | ||
|
|
a59f6097d7 | ||
|
|
887c6243e2 | ||
|
|
127432f2ec | ||
|
|
4f0439dad9 | ||
|
|
9c188736f9 | ||
|
|
a69dbb3d4f | ||
|
|
b2e21f06a8 | ||
|
|
a325bb554a | ||
|
|
aa5e3d9437 | ||
|
|
6346954e7a | ||
|
|
5b6f7dd3ee | ||
|
|
7199db5edb | ||
|
|
8644b858b3 | ||
|
|
3d475217ca | ||
|
|
f580bc60f5 | ||
|
|
1a4f8563f2 | ||
|
|
a021ca19a5 | ||
|
|
2a4f8e3ff9 | ||
|
|
3298918322 | ||
|
|
f068aa5390 | ||
|
|
6e6ab56163 | ||
|
|
91204955c9 | ||
|
|
bc3552dda7 | ||
|
|
d459be2942 | ||
|
|
1c5c76de61 | ||
|
|
cb6817449d | ||
|
|
ffa006225b | ||
|
|
11d9a13ac7 | ||
|
|
21d5af367b | ||
|
|
2882fa2d0a | ||
|
|
a035b67e6c | ||
|
|
6979affb86 | ||
|
|
bb9c3a8df0 |
13
.github/workflows/build.yml
vendored
13
.github/workflows/build.yml
vendored
@@ -1,16 +1,15 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 14
|
||||
node-version: 16
|
||||
- run: npm ci
|
||||
- run: npm run jslint
|
||||
- run: docker pull drachtio/sipp
|
||||
@@ -20,3 +19,5 @@ jobs:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||
MICROSOFT_REGION: ${{ secrets.MICROSOFT_REGION }}
|
||||
MICROSOFT_API_KEY: ${{ secrets.MICROSOFT_API_KEY }}
|
||||
61
.github/workflows/docker-publish.yml
vendored
61
.github/workflows/docker-publish.yml
vendored
@@ -2,16 +2,8 @@ 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:
|
||||
@@ -20,32 +12,41 @@ jobs:
|
||||
if: github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- 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
|
||||
- name: prepare tag
|
||||
id: prepare_tag
|
||||
run: |
|
||||
IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME
|
||||
IMAGE_ID=feature-server
|
||||
|
||||
# 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 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//')
|
||||
|
||||
# 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
|
||||
|
||||
# Use Docker `latest` tag convention
|
||||
[ "$VERSION" == "main" ] && VERSION=latest
|
||||
echo IMAGE_ID=$IMAGE_ID
|
||||
echo VERSION=$VERSION
|
||||
|
||||
echo IMAGE_ID=$IMAGE_ID
|
||||
echo VERSION=$VERSION
|
||||
echo "image_id=$IMAGE_ID" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
docker tag $IMAGE_NAME $IMAGE_ID:$VERSION
|
||||
docker push $IMAGE_ID:$VERSION
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.prepare_tag.outputs.image_id }}:${{ steps.prepare_tag.outputs.version }}
|
||||
build-args: |
|
||||
GITHUB_REPOSITORY=$GITHUB_REPOSITORY
|
||||
GITHUB_REF=$GITHUB_REF
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -40,4 +40,5 @@ examples/*
|
||||
ecosystem.config.js
|
||||
.vscode
|
||||
test/credentials/*.json
|
||||
run-tests.sh
|
||||
run-tests.sh
|
||||
run-coverage.sh
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM --platform=linux/amd64 node:16.15.1-alpine as base
|
||||
FROM --platform=linux/amd64 node:18.15-alpine3.16 as base
|
||||
|
||||
RUN apk --update --no-cache add --virtual .builds-deps build-base python3
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@ Configuration is provided via environment variables:
|
||||
|DRACHTIO_PORT| listening port of drachtio server for control connections (typically 9022)|yes|
|
||||
|DRACHTIO_SECRET| shared secret|yes|
|
||||
|ENABLE_METRICS| if 1, metrics will be generated|no|
|
||||
|ENCRYPTION_SECRET| secret for credential encryption(JWT_SECRET is deprecated) |yes|
|
||||
|GOOGLE_APPLICATION_CREDENTIALS| path to gcp service key file|yes|
|
||||
|HTTP_PORT| tcp port to listen on for API requests from jambonz-api-server|yes|
|
||||
|JAMBONES_GATHER_EARLY_HINTS_MATCH| if true and hints are provided, gather will opportunistically review interim transcripts if possible to reduce ASR latency |no|
|
||||
|JAMBONES_FREESWITCH| IP:port:secret for Freeswitch server (e.g. '127.0.0.1:8021:JambonzR0ck$'|yes|
|
||||
|JAMBONES_LOGLEVEL| log level for application, 'info' or 'debug'|no|
|
||||
|JAMBONES_MYSQL_HOST| mysql host|yes|
|
||||
@@ -87,4 +89,4 @@ module.exports = {
|
||||
|
||||
#### Running the test suite
|
||||
|
||||
Please [see this]](./docs/contributing.md#run-the-regression-test-suite).
|
||||
Please [see this](./docs/contributing.md#run-the-regression-test-suite).
|
||||
124
app.js
124
app.js
@@ -1,26 +1,28 @@
|
||||
const assert = require('assert');
|
||||
assert.ok(process.env.JAMBONES_MYSQL_HOST &&
|
||||
process.env.JAMBONES_MYSQL_USER &&
|
||||
process.env.JAMBONES_MYSQL_PASSWORD &&
|
||||
process.env.JAMBONES_MYSQL_DATABASE, 'missing JAMBONES_MYSQL_XXX env vars');
|
||||
assert.ok(process.env.DRACHTIO_PORT || process.env.DRACHTIO_HOST, 'missing DRACHTIO_PORT env var');
|
||||
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 || process.env.K8S, 'missing JAMBONES_SUBNET env var');
|
||||
const {
|
||||
DRACHTIO_PORT,
|
||||
DRACHTIO_HOST,
|
||||
DRACHTIO_SECRET,
|
||||
JAMBONES_OTEL_SERVICE_NAME,
|
||||
JAMBONES_LOGLEVEL,
|
||||
JAMBONES_CLUSTER_ID,
|
||||
JAMBONZ_CLEANUP_INTERVAL_MINS,
|
||||
getCleanupIntervalMins,
|
||||
K8S,
|
||||
NODE_ENV,
|
||||
checkEnvs,
|
||||
} = require('./lib/config');
|
||||
|
||||
checkEnvs();
|
||||
|
||||
const Srf = require('drachtio-srf');
|
||||
const srf = new Srf();
|
||||
const tracer = require('./tracer')(process.env.JAMBONES_OTEL_SERVICE_NAME || 'jambonz-feature-server');
|
||||
const tracer = require('./tracer')(JAMBONES_OTEL_SERVICE_NAME);
|
||||
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 opts = {level: JAMBONES_LOGLEVEL};
|
||||
const pino = require('pino');
|
||||
const logger = pino(opts, pino.destination({sync: false}));
|
||||
const {LifeCycleEvents, FS_UUID_SET_NAME} = require('./lib/utils/constants');
|
||||
const installSrfLocals = require('./lib/utils/install-srf-locals');
|
||||
installSrfLocals(srf, logger);
|
||||
@@ -28,27 +30,18 @@ installSrfLocals(srf, logger);
|
||||
const {
|
||||
initLocals,
|
||||
createRootSpan,
|
||||
handleSipRec,
|
||||
getAccountDetails,
|
||||
normalizeNumbers,
|
||||
retrieveApplication,
|
||||
invokeWebCallback
|
||||
} = require('./lib/middleware')(srf, logger);
|
||||
|
||||
// HTTP
|
||||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const app = express();
|
||||
Object.assign(app.locals, {
|
||||
logger,
|
||||
srf
|
||||
});
|
||||
|
||||
const httpRoutes = require('./lib/http-routes');
|
||||
|
||||
const InboundCallSession = require('./lib/session/inbound-call-session');
|
||||
const SipRecCallSession = require('./lib/session/siprec-call-session');
|
||||
|
||||
if (process.env.DRACHTIO_HOST) {
|
||||
srf.connect({host: process.env.DRACHTIO_HOST, port: process.env.DRACHTIO_PORT, secret: process.env.DRACHTIO_SECRET });
|
||||
if (DRACHTIO_HOST) {
|
||||
srf.connect({host: DRACHTIO_HOST, port: DRACHTIO_PORT, secret: DRACHTIO_SECRET });
|
||||
srf.on('connect', (err, hp) => {
|
||||
const arr = /^(.*)\/(.*)$/.exec(hp.split(',').pop());
|
||||
srf.locals.localSipAddress = `${arr[2]}`;
|
||||
@@ -56,10 +49,10 @@ if (process.env.DRACHTIO_HOST) {
|
||||
});
|
||||
}
|
||||
else {
|
||||
logger.info(`listening for drachtio requests on port ${process.env.DRACHTIO_PORT}`);
|
||||
srf.listen({port: process.env.DRACHTIO_PORT, secret: process.env.DRACHTIO_SECRET});
|
||||
logger.info(`listening for drachtio requests on port ${DRACHTIO_PORT}`);
|
||||
srf.listen({port: DRACHTIO_PORT, secret: DRACHTIO_SECRET});
|
||||
}
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
if (NODE_ENV === 'test') {
|
||||
srf.on('error', (err) => {
|
||||
logger.info(err, 'Error connecting to drachtio');
|
||||
});
|
||||
@@ -68,31 +61,20 @@ if (process.env.NODE_ENV === 'test') {
|
||||
srf.use('invite', [
|
||||
initLocals,
|
||||
createRootSpan,
|
||||
handleSipRec,
|
||||
getAccountDetails,
|
||||
normalizeNumbers,
|
||||
retrieveApplication,
|
||||
invokeWebCallback
|
||||
]);
|
||||
|
||||
srf.invite((req, res) => {
|
||||
const session = new InboundCallSession(req, res);
|
||||
srf.invite(async(req, res) => {
|
||||
const isSipRec = !!req.locals.siprec;
|
||||
const session = isSipRec ? new SipRecCallSession(req, res) : new InboundCallSession(req, res);
|
||||
if (isSipRec) await session.answerSipRecCall();
|
||||
session.exec();
|
||||
});
|
||||
|
||||
// HTTP
|
||||
app.use(helmet());
|
||||
app.use(helmet.hidePoweredBy());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.use('/', httpRoutes);
|
||||
app.use((err, req, res, next) => {
|
||||
logger.error(err, 'burped error');
|
||||
res.status(err.status || 500).json({msg: err.message});
|
||||
});
|
||||
const httpServer = app.listen(PORT);
|
||||
|
||||
logger.info(`listening for HTTP requests on port ${PORT}, serviceUrl is ${srf.locals.serviceUrl}`);
|
||||
|
||||
const sessionTracker = srf.locals.sessionTracker = require('./lib/session/session-tracker');
|
||||
sessionTracker.on('idle', () => {
|
||||
if (srf.locals.lifecycleEmitter.operationalState === LifeCycleEvents.ScaleIn) {
|
||||
@@ -100,37 +82,57 @@ sessionTracker.on('idle', () => {
|
||||
srf.locals.lifecycleEmitter.scaleIn();
|
||||
}
|
||||
});
|
||||
|
||||
const getCount = () => sessionTracker.count;
|
||||
const healthCheck = require('@jambonz/http-health-check');
|
||||
healthCheck({app, logger, path: '/', fn: getCount});
|
||||
let httpServer;
|
||||
|
||||
const createHttpListener = require('./lib/utils/http-listener');
|
||||
createHttpListener(logger, srf)
|
||||
.then(({server, app}) => {
|
||||
httpServer = server;
|
||||
healthCheck({app, logger, path: '/', fn: getCount});
|
||||
return {server, app};
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error(err, 'Error creating http listener');
|
||||
});
|
||||
|
||||
|
||||
setInterval(() => {
|
||||
srf.locals.stats.gauge('fs.sip.calls.count', sessionTracker.count);
|
||||
}, 5000);
|
||||
}, 20000);
|
||||
|
||||
const disconnect = () => {
|
||||
return new Promise ((resolve) => {
|
||||
httpServer.on('close', resolve);
|
||||
httpServer.close();
|
||||
httpServer?.on('close', resolve);
|
||||
httpServer?.close();
|
||||
srf.disconnect();
|
||||
srf.locals.mediaservers.forEach((ms) => ms.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;
|
||||
logger.info(`got signal ${signal}`);
|
||||
const setName = `${(JAMBONES_CLUSTER_ID || 'default')}:active-fs`;
|
||||
if (setName && srf.locals.localSipAddress) {
|
||||
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);
|
||||
if (K8S) {
|
||||
srf.locals.lifecycleEmitter.operationalState = LifeCycleEvents.ScaleIn;
|
||||
}
|
||||
if (getCount() === 0) {
|
||||
logger.info('no calls in progress, exiting');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.JAMBONZ_CLEANUP_INTERVAL_MINS) {
|
||||
if (JAMBONZ_CLEANUP_INTERVAL_MINS) {
|
||||
const {clearFiles} = require('./lib/utils/cron-jobs');
|
||||
|
||||
/* cleanup orphaned files or channels every so often */
|
||||
@@ -140,7 +142,7 @@ if (process.env.JAMBONZ_CLEANUP_INTERVAL_MINS) {
|
||||
} catch (err) {
|
||||
logger.error({err}, 'app.js: error clearing files');
|
||||
}
|
||||
}, 1000 * 60 * (process.env.JAMBONZ_CLEANUP_INTERVAL_MINS || 60));
|
||||
}, getCleanupIntervalMins());
|
||||
}
|
||||
|
||||
module.exports = {srf, logger, disconnect};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
const bent = require('bent');
|
||||
const getJSON = bent('json');
|
||||
const PORT = process.env.HTTP_PORT || 3000;
|
||||
const {PORT} = require('../lib/config')
|
||||
|
||||
const sleep = (ms) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
62
data/example-voicemail-greetings.json
Normal file
62
data/example-voicemail-greetings.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"en-US": [
|
||||
"call has been forwarded",
|
||||
"at the beep",
|
||||
"at the tone",
|
||||
"leave a message",
|
||||
"leave me a message",
|
||||
"not available",
|
||||
"can't take your call",
|
||||
"will get back to you",
|
||||
"I'll get back to you",
|
||||
"we are unable"
|
||||
],
|
||||
"es-ES": [
|
||||
"le pasamos la llamada",
|
||||
"después del bip",
|
||||
"después del tono",
|
||||
"deja un mensaje",
|
||||
"déjame un mensaje",
|
||||
"no estamos disponibles",
|
||||
"no estoy disponible",
|
||||
"ahora no puedo",
|
||||
"no puedo contestar",
|
||||
"no le puedo contestar",
|
||||
"me pondré en contacto",
|
||||
"nos pondremos en contacto",
|
||||
"ahora no estamos disponibles",
|
||||
"no estamos disponibles"
|
||||
],
|
||||
"ca-ES": [
|
||||
"passem la seva trucada",
|
||||
"després del bip",
|
||||
"després del to",
|
||||
"deixi un missatge",
|
||||
"deixa un missatge",
|
||||
"deixim un missatge",
|
||||
"no estem disponibles",
|
||||
"no estem a l'oficina",
|
||||
"no estic disponible",
|
||||
"ara no puc",
|
||||
"no puc contestar",
|
||||
"no puc respondre",
|
||||
"no li puc respondre",
|
||||
"em posaré en contacte",
|
||||
"ens posarem en contacto",
|
||||
"ara no estem disponibles",
|
||||
"no hi som"
|
||||
],
|
||||
"de-DE": [
|
||||
"nicht erreichbar",
|
||||
"nnruf wurde weitergeleitet",
|
||||
"beim piepsen",
|
||||
"am ton",
|
||||
"eine nachricht hinterlassen",
|
||||
"hinterlasse mir eine Nachricht",
|
||||
"nicht verfügbar",
|
||||
"kann ihren anruf nicht entgegennehmen",
|
||||
"wird sich bei Ihnen melden",
|
||||
"ich melde mich bei dir",
|
||||
"wir können nicht"
|
||||
]
|
||||
}
|
||||
195
lib/config.js
Normal file
195
lib/config.js
Normal file
@@ -0,0 +1,195 @@
|
||||
const assert = require('assert');
|
||||
|
||||
const checkEnvs = () => {
|
||||
assert.ok(process.env.JAMBONES_MYSQL_HOST &&
|
||||
process.env.JAMBONES_MYSQL_USER &&
|
||||
process.env.JAMBONES_MYSQL_PASSWORD &&
|
||||
process.env.JAMBONES_MYSQL_DATABASE, 'missing JAMBONES_MYSQL_XXX env vars');
|
||||
assert.ok(process.env.DRACHTIO_PORT || process.env.DRACHTIO_HOST, 'missing DRACHTIO_PORT env var');
|
||||
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 || process.env.K8S, 'missing JAMBONES_SUBNET env var');
|
||||
};
|
||||
|
||||
const NODE_ENV = process.env.NODE_ENV;
|
||||
|
||||
/* database mySQL */
|
||||
const JAMBONES_MYSQL_HOST = process.env.JAMBONES_MYSQL_HOST;
|
||||
const JAMBONES_MYSQL_USER = process.env.JAMBONES_MYSQL_USER;
|
||||
const JAMBONES_MYSQL_PASSWORD = process.env.JAMBONES_MYSQL_PASSWORD;
|
||||
const JAMBONES_MYSQL_DATABASE = process.env.JAMBONES_MYSQL_DATABASE;
|
||||
const JAMBONES_MYSQL_PORT = parseInt(process.env.JAMBONES_MYSQL_PORT, 10) || 3306;
|
||||
const JAMBONES_MYSQL_REFRESH_TTL = process.env.JAMBONES_MYSQL_REFRESH_TTL;
|
||||
const JAMBONES_MYSQL_CONNECTION_LIMIT = parseInt(process.env.JAMBONES_MYSQL_CONNECTION_LIMIT, 10) || 10;
|
||||
|
||||
/* redis */
|
||||
const JAMBONES_REDIS_HOST = process.env.JAMBONES_REDIS_HOST;
|
||||
const JAMBONES_REDIS_PORT = parseInt(process.env.JAMBONES_REDIS_PORT, 10) || 6379;
|
||||
|
||||
/* gather and hints */
|
||||
const JAMBONES_GATHER_EARLY_HINTS_MATCH = process.env.JAMBONES_GATHER_EARLY_HINTS_MATCH;
|
||||
const JAMBONZ_GATHER_EARLY_HINTS_MATCH = process.env.JAMBONZ_GATHER_EARLY_HINTS_MATCH;
|
||||
const JAMBONES_GATHER_CLEAR_GLOBAL_HINTS_ON_EMPTY_HINTS = process.env.JAMBONES_GATHER_CLEAR_GLOBAL_HINTS_ON_EMPTY_HINTS;
|
||||
|
||||
const SMPP_URL = process.env.SMPP_URL;
|
||||
|
||||
/* drachtio */
|
||||
const DRACHTIO_PORT = process.env.DRACHTIO_PORT;
|
||||
const DRACHTIO_HOST = process.env.DRACHTIO_HOST;
|
||||
const DRACHTIO_SECRET = process.env.DRACHTIO_SECRET;
|
||||
|
||||
/* freeswitch */
|
||||
const JAMBONES_API_BASE_URL = process.env.JAMBONES_API_BASE_URL;
|
||||
const JAMBONES_FREESWITCH = process.env.JAMBONES_FREESWITCH;
|
||||
const JAMBONES_FREESWITCH_MAX_CALL_DURATION_MINS = parseInt(process.env.JAMBONES_FREESWITCH_MAX_CALL_DURATION_MINS, 10)
|
||||
|| 180;
|
||||
|
||||
|
||||
const JAMBONES_SBCS = process.env.JAMBONES_SBCS;
|
||||
|
||||
/* websockets */
|
||||
const JAMBONES_WS_HANDSHAKE_TIMEOUT_MS = parseInt(process.env.JAMBONES_WS_HANDSHAKE_TIMEOUT_MS, 10) || 1500;
|
||||
const JAMBONES_WS_MAX_PAYLOAD = parseInt(process.env.JAMBONES_WS_MAX_PAYLOAD, 10) || 24 * 1024;
|
||||
|
||||
const MAX_RECONNECTS = 5;
|
||||
const RESPONSE_TIMEOUT_MS = parseInt(process.env.JAMBONES_WS_API_MSG_RESPONSE_TIMEOUT, 10) || 5000;
|
||||
|
||||
const JAMBONES_NETWORK_CIDR = process.env.JAMBONES_NETWORK_CIDR;
|
||||
const JAMBONES_TIME_SERIES_HOST = process.env.JAMBONES_TIME_SERIES_HOST;
|
||||
const JAMBONES_CLUSTER_ID = process.env.JAMBONES_CLUSTER_ID || 'default';
|
||||
const JAMBONES_ESL_LISTEN_ADDRESS = process.env.JAMBONES_ESL_LISTEN_ADDRESS;
|
||||
|
||||
/* tracing */
|
||||
const JAMBONES_OTEL_ENABLED = process.env.JAMBONES_OTEL_ENABLED;
|
||||
const JAMBONES_OTEL_SERVICE_NAME = process.env.JAMBONES_OTEL_SERVICE_NAME || 'jambonz-feature-server';
|
||||
const OTEL_EXPORTER_JAEGER_AGENT_HOST = process.env.OTEL_EXPORTER_JAEGER_AGENT_HOST;
|
||||
const OTEL_EXPORTER_JAEGER_ENDPOINT = process.env.OTEL_EXPORTER_JAEGER_ENDPOINT;
|
||||
const OTEL_EXPORTER_ZIPKIN_URL = process.env.OTEL_EXPORTER_ZIPKIN_URL;
|
||||
const OTEL_EXPORTER_COLLECTOR_URL = process.env.OTEL_EXPORTER_COLLECTOR_URL;
|
||||
|
||||
const JAMBONES_LOGLEVEL = process.env.JAMBONES_LOGLEVEL || 'info';
|
||||
const JAMBONES_INJECT_CONTENT = process.env.JAMBONES_INJECT_CONTENT;
|
||||
|
||||
const PORT = parseInt(process.env.HTTP_PORT, 10) || 3000;
|
||||
const HTTP_PORT_MAX = parseInt(process.env.HTTP_PORT_MAX, 10);
|
||||
|
||||
const K8S = process.env.K8S;
|
||||
const K8S_SBC_SIP_SERVICE_NAME = process.env.K8S_SBC_SIP_SERVICE_NAME;
|
||||
|
||||
const JAMBONES_SUBNET = process.env.JAMBONES_SUBNET;
|
||||
|
||||
/* clean up */
|
||||
const JAMBONZ_CLEANUP_INTERVAL_MINS = process.env.JAMBONZ_CLEANUP_INTERVAL_MINS;
|
||||
const getCleanupIntervalMins = () => {
|
||||
const interval = parseInt(JAMBONZ_CLEANUP_INTERVAL_MINS, 10) || 60;
|
||||
return 1000 * 60 * interval;
|
||||
};
|
||||
|
||||
/* speech vendors */
|
||||
const AWS_REGION = process.env.AWS_REGION;
|
||||
const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID;
|
||||
const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY;
|
||||
const AWS_SNS_PORT = parseInt(process.env.AWS_SNS_PORT, 10) || 3001;
|
||||
const AWS_SNS_TOPIC_ARM = process.env.AWS_SNS_TOPIC_ARM;
|
||||
const AWS_SNS_PORT_MAX = parseInt(process.env.AWS_SNS_PORT_MAX, 10) || 3005;
|
||||
|
||||
const GCP_JSON_KEY = process.env.GCP_JSON_KEY;
|
||||
|
||||
const MICROSOFT_REGION = process.env.MICROSOFT_REGION;
|
||||
const MICROSOFT_API_KEY = process.env.MICROSOFT_API_KEY;
|
||||
|
||||
const SONIOX_API_KEY = process.env.SONIOX_API_KEY;
|
||||
|
||||
const DEEPGRAM_API_KEY = process.env.DEEPGRAM_API_KEY;
|
||||
|
||||
const ANCHOR_MEDIA_ALWAYS = process.env.ANCHOR_MEDIA_ALWAYS;
|
||||
const VMD_HINTS_FILE = process.env.VMD_HINTS_FILE;
|
||||
|
||||
/* security, secrets */
|
||||
const LEGACY_CRYPTO = !!process.env.LEGACY_CRYPTO;
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
const ENCRYPTION_SECRET = process.env.ENCRYPTION_SECRET;
|
||||
|
||||
/* HTTP/1 pool dispatcher */
|
||||
const HTTP_POOL = process.env.HTTP_POOL && parseInt(process.env.HTTP_POOL);
|
||||
const HTTP_POOLSIZE = parseInt(process.env.HTTP_POOLSIZE, 10) || 10;
|
||||
const HTTP_PIPELINING = parseInt(process.env.HTTP_PIPELINING, 10) || 1;
|
||||
const HTTP_TIMEOUT = 10000;
|
||||
|
||||
const OPTIONS_PING_INTERVAL = parseInt(process.env.OPTIONS_PING_INTERVAL, 10) || 30000;
|
||||
|
||||
module.exports = {
|
||||
JAMBONES_MYSQL_HOST,
|
||||
JAMBONES_MYSQL_USER,
|
||||
JAMBONES_MYSQL_PASSWORD,
|
||||
JAMBONES_MYSQL_DATABASE,
|
||||
JAMBONES_MYSQL_REFRESH_TTL,
|
||||
JAMBONES_MYSQL_CONNECTION_LIMIT,
|
||||
JAMBONES_MYSQL_PORT,
|
||||
|
||||
DRACHTIO_PORT,
|
||||
DRACHTIO_HOST,
|
||||
DRACHTIO_SECRET,
|
||||
|
||||
JAMBONES_GATHER_EARLY_HINTS_MATCH,
|
||||
JAMBONZ_GATHER_EARLY_HINTS_MATCH,
|
||||
JAMBONES_GATHER_CLEAR_GLOBAL_HINTS_ON_EMPTY_HINTS,
|
||||
JAMBONES_FREESWITCH,
|
||||
JAMBONES_REDIS_HOST,
|
||||
JAMBONES_REDIS_PORT,
|
||||
SMPP_URL,
|
||||
JAMBONES_NETWORK_CIDR,
|
||||
JAMBONES_API_BASE_URL,
|
||||
JAMBONES_TIME_SERIES_HOST,
|
||||
JAMBONES_INJECT_CONTENT,
|
||||
JAMBONES_ESL_LISTEN_ADDRESS,
|
||||
JAMBONES_SBCS,
|
||||
JAMBONES_OTEL_ENABLED,
|
||||
JAMBONES_OTEL_SERVICE_NAME,
|
||||
OTEL_EXPORTER_JAEGER_AGENT_HOST,
|
||||
OTEL_EXPORTER_JAEGER_ENDPOINT,
|
||||
OTEL_EXPORTER_ZIPKIN_URL,
|
||||
OTEL_EXPORTER_COLLECTOR_URL,
|
||||
|
||||
JAMBONES_LOGLEVEL,
|
||||
JAMBONES_CLUSTER_ID,
|
||||
PORT,
|
||||
HTTP_PORT_MAX,
|
||||
K8S,
|
||||
K8S_SBC_SIP_SERVICE_NAME,
|
||||
JAMBONES_SUBNET,
|
||||
NODE_ENV,
|
||||
JAMBONZ_CLEANUP_INTERVAL_MINS,
|
||||
getCleanupIntervalMins,
|
||||
checkEnvs,
|
||||
|
||||
AWS_REGION,
|
||||
AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY,
|
||||
AWS_SNS_PORT,
|
||||
AWS_SNS_TOPIC_ARM,
|
||||
AWS_SNS_PORT_MAX,
|
||||
|
||||
ANCHOR_MEDIA_ALWAYS,
|
||||
VMD_HINTS_FILE,
|
||||
JAMBONES_FREESWITCH_MAX_CALL_DURATION_MINS,
|
||||
|
||||
LEGACY_CRYPTO,
|
||||
JWT_SECRET,
|
||||
ENCRYPTION_SECRET,
|
||||
HTTP_POOL,
|
||||
HTTP_POOLSIZE,
|
||||
HTTP_PIPELINING,
|
||||
HTTP_TIMEOUT,
|
||||
OPTIONS_PING_INTERVAL,
|
||||
RESPONSE_TIMEOUT_MS,
|
||||
JAMBONES_WS_HANDSHAKE_TIMEOUT_MS,
|
||||
JAMBONES_WS_MAX_PAYLOAD,
|
||||
MAX_RECONNECTS,
|
||||
GCP_JSON_KEY,
|
||||
MICROSOFT_REGION,
|
||||
MICROSOFT_API_KEY,
|
||||
SONIOX_API_KEY,
|
||||
DEEPGRAM_API_KEY
|
||||
};
|
||||
@@ -3,7 +3,7 @@ const makeTask = require('../../tasks/make_task');
|
||||
const RestCallSession = require('../../session/rest-call-session');
|
||||
const CallInfo = require('../../session/call-info');
|
||||
const {CallDirection, CallStatus} = require('../../utils/constants');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const uuidv4 = require('uuid-random');
|
||||
const SipError = require('drachtio-srf').SipError;
|
||||
const sysError = require('./error');
|
||||
const HttpRequestor = require('../../utils/http-requestor');
|
||||
@@ -27,6 +27,7 @@ router.post('/', async(req, res) => {
|
||||
const target = restDial.to;
|
||||
const opts = {
|
||||
callingNumber: restDial.from,
|
||||
...(restDial.callerName && {callingName: restDial.callerName}),
|
||||
headers: req.body.headers || {}
|
||||
};
|
||||
|
||||
@@ -41,7 +42,8 @@ router.post('/', async(req, res) => {
|
||||
'X-Jambonz-Routing': target.type,
|
||||
'X-Jambonz-FS-UUID': srf.locals.fsUUID,
|
||||
'X-Call-Sid': callSid,
|
||||
'X-Account-Sid': accountSid
|
||||
'X-Account-Sid': accountSid,
|
||||
...(restDial.fromHost && {'X-Preferred-From-Host': restDial.fromHost})
|
||||
};
|
||||
|
||||
switch (target.type) {
|
||||
@@ -84,6 +86,19 @@ router.post('/', async(req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* trunk isn't specified,
|
||||
* check if from-number matches any existing numbers on Jambonz
|
||||
* */
|
||||
if (target.type === 'phone' && !target.trunk) {
|
||||
const {lookupCarrierByPhoneNumber} = dbUtils(this.logger, srf);
|
||||
const voip_carrier_sid = await lookupCarrierByPhoneNumber(req.body.account_sid, restDial.from);
|
||||
logger.info(
|
||||
`createCall: selected ${voip_carrier_sid} for requested phone number: ${restDial.from || 'unspecified'})`);
|
||||
if (voip_carrier_sid) {
|
||||
opts.headers['X-Requested-Carrier-Sid'] = voip_carrier_sid;
|
||||
}
|
||||
}
|
||||
|
||||
/* create endpoint for outdial */
|
||||
const ms = getFreeswitch();
|
||||
@@ -104,7 +119,7 @@ router.post('/', async(req, res) => {
|
||||
proxy: `sip:${sbcAddress}`,
|
||||
localSdp: ep.local.sdp
|
||||
});
|
||||
if (target.auth) opts.auth = this.target.auth;
|
||||
if (target.auth) opts.auth = target.auth;
|
||||
|
||||
|
||||
/**
|
||||
@@ -136,7 +151,7 @@ router.post('/', async(req, res) => {
|
||||
}
|
||||
else if (!app.notifier) {
|
||||
logger.debug('creating null call status hook');
|
||||
app.notifier = {request: () => {}};
|
||||
app.notifier = {request: () => {}, close: () => {}};
|
||||
}
|
||||
|
||||
/* now launch the outdial */
|
||||
@@ -197,9 +212,10 @@ router.post('/', async(req, res) => {
|
||||
});
|
||||
cs.exec(req);
|
||||
|
||||
res.status(201).json({sid: cs.callSid});
|
||||
res.status(201).json({sid: cs.callSid, callId: inviteReq.get('Call-ID')});
|
||||
|
||||
sipLogger.info(`outbound REST call attempt to ${JSON.stringify(target)} has been sent`);
|
||||
sipLogger.info({sid: cs.callSid, callId: inviteReq.get('Call-ID')},
|
||||
`outbound REST call attempt to ${JSON.stringify(target)} has been sent`);
|
||||
},
|
||||
cbProvisional: (prov) => {
|
||||
const callStatus = prov.body ? CallStatus.EarlyMedia : CallStatus.Ringing;
|
||||
|
||||
@@ -2,7 +2,7 @@ const router = require('express').Router();
|
||||
const CallInfo = require('../../session/call-info');
|
||||
const {CallDirection} = require('../../utils/constants');
|
||||
const SmsSession = require('../../session/sms-call-session');
|
||||
const normalizeJambones = require('../../utils/normalize-jambones');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const makeTask = require('../../tasks/make_task');
|
||||
|
||||
router.post('/:sid', async(req, res) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ const WsRequestor = require('../../utils/ws-requestor');
|
||||
const CallInfo = require('../../session/call-info');
|
||||
const {CallDirection} = require('../../utils/constants');
|
||||
const SmsSession = require('../../session/sms-call-session');
|
||||
const normalizeJambones = require('../../utils/normalize-jambones');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const {TaskPreconditions} = require('../../utils/constants');
|
||||
const makeTask = require('../../tasks/make_task');
|
||||
|
||||
@@ -34,6 +34,7 @@ router.post('/:partner', async(req, res) => {
|
||||
carrier: req.params.partner,
|
||||
messageSid: app.messageSid,
|
||||
accountSid: app.accountSid,
|
||||
serviceProviderSid: account.service_provider_sid,
|
||||
applicationSid: app.applicationSid,
|
||||
from: req.body.from,
|
||||
to: req.body.to,
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const {CallDirection} = require('./utils/constants');
|
||||
const uuidv4 = require('uuid-random');
|
||||
const {CallDirection, AllowedSipRecVerbs} = require('./utils/constants');
|
||||
const {parseSiprecPayload} = require('./utils/siprec-utils');
|
||||
const CallInfo = require('./session/call-info');
|
||||
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 { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const dbUtils = require('./utils/db-utils');
|
||||
const RootSpan = require('./utils/call-tracer');
|
||||
const listTaskNames = require('./utils/summarize-tasks');
|
||||
const {
|
||||
JAMBONES_MYSQL_REFRESH_TTL,
|
||||
} = require('./config');
|
||||
|
||||
module.exports = function(srf, logger) {
|
||||
const {
|
||||
@@ -18,16 +22,26 @@ module.exports = function(srf, logger) {
|
||||
lookupAppByRealm,
|
||||
lookupAppByTeamsTenant
|
||||
} = srf.locals.dbHelpers;
|
||||
const {
|
||||
writeAlerts,
|
||||
AlertType
|
||||
} = srf.locals;
|
||||
const {lookupAccountDetails} = dbUtils(logger, srf);
|
||||
|
||||
function initLocals(req, res, next) {
|
||||
const callId = req.get('Call-ID');
|
||||
logger.info({
|
||||
callId,
|
||||
callingNumber: req.callingNumber,
|
||||
calledNumber: req.calledNumber
|
||||
}, 'new incoming call');
|
||||
if (!req.has('X-Account-Sid')) {
|
||||
logger.info('getAccountDetails - rejecting call due to missing X-Account-Sid header');
|
||||
return res.send(500);
|
||||
}
|
||||
const callSid = req.has('X-Retain-Call-Sid') ? req.get('X-Retain-Call-Sid') : uuidv4();
|
||||
const account_sid = req.get('X-Account-Sid');
|
||||
req.locals = {callSid, account_sid};
|
||||
req.locals = {callSid, account_sid, callId};
|
||||
if (req.has('X-Application-Sid')) {
|
||||
const application_sid = req.get('X-Application-Sid');
|
||||
logger.debug(`got application from X-Application-Sid header: ${application_sid}`);
|
||||
@@ -35,12 +49,21 @@ module.exports = function(srf, logger) {
|
||||
}
|
||||
if (req.has('X-Authenticated-User')) req.locals.originatingUser = req.get('X-Authenticated-User');
|
||||
if (req.has('X-MS-Teams-Tenant-FQDN')) req.locals.msTeamsTenant = req.get('X-MS-Teams-Tenant-FQDN');
|
||||
|
||||
if (req.has('X-Cisco-Recording-Participant')) {
|
||||
const ciscoParticipants = req.get('X-Cisco-Recording-Participant');
|
||||
const regex = /sip:[\d]+@[\d]+\.[\d]+\.[\d]+\.[\d]+/g;
|
||||
const sipURIs = ciscoParticipants.match(regex);
|
||||
logger.info(`X-Cisco-Recording-Participant : ${sipURIs} `);
|
||||
if (sipURIs && sipURIs.length > 0) {
|
||||
req.locals.calledNumber = sipURIs[0];
|
||||
req.locals.callingNumber = sipURIs[1];
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
function createRootSpan(req, res, next) {
|
||||
const {callSid, account_sid} = req.locals;
|
||||
const {callId, callSid, account_sid} = req.locals;
|
||||
const rootSpan = new RootSpan('incoming-call', req);
|
||||
const traceId = rootSpan.traceId;
|
||||
|
||||
@@ -48,7 +71,7 @@ module.exports = function(srf, logger) {
|
||||
...req.locals,
|
||||
traceId,
|
||||
logger: logger.child({
|
||||
callId: req.get('Call-ID'),
|
||||
callId,
|
||||
callSid,
|
||||
accountSid: account_sid,
|
||||
callingNumber: req.callingNumber,
|
||||
@@ -73,6 +96,37 @@ module.exports = function(srf, logger) {
|
||||
next();
|
||||
}
|
||||
|
||||
const handleSipRec = async(req, res, next) => {
|
||||
if (Array.isArray(req.payload) && req.payload.length > 1) {
|
||||
const {callId, logger} = req.locals;
|
||||
logger.debug({payload: req.payload}, 'handling siprec call');
|
||||
|
||||
try {
|
||||
const sdp = req.payload
|
||||
.find((p) => p.type === 'application/sdp')
|
||||
.content;
|
||||
const {sdp1, sdp2, ...metadata} = await parseSiprecPayload(req, logger);
|
||||
if (!req.locals.calledNumber && !req.locals.calledNumber) {
|
||||
req.locals.calledNumber = metadata.caller.number;
|
||||
req.locals.callingNumber = metadata.callee.number;
|
||||
}
|
||||
req.locals = {
|
||||
...req.locals,
|
||||
siprec: {
|
||||
metadata,
|
||||
sdp1,
|
||||
sdp2
|
||||
}
|
||||
};
|
||||
logger.info({callId, metadata, sdp}, 'successfully parsed SIPREC payload');
|
||||
} catch (err) {
|
||||
logger.info({callId}, 'Error parsing multipart payload');
|
||||
return res.send(503);
|
||||
}
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* retrieve account information for the incoming call
|
||||
*/
|
||||
@@ -82,6 +136,7 @@ module.exports = function(srf, logger) {
|
||||
const {span} = rootSpan.startChildSpan('lookupAccountDetails');
|
||||
try {
|
||||
req.locals.accountInfo = await lookupAccountDetails(account_sid);
|
||||
req.locals.service_provider_sid = req.locals.accountInfo?.account?.service_provider_sid;
|
||||
span.end();
|
||||
if (!req.locals.accountInfo.account.is_active) {
|
||||
logger.info(`Account is inactive or suspended ${account_sid}`);
|
||||
@@ -101,7 +156,10 @@ module.exports = function(srf, logger) {
|
||||
* Within the system, we deal with E.164 numbers _without_ the leading '+
|
||||
*/
|
||||
function normalizeNumbers(req, res, next) {
|
||||
const logger = req.locals.logger;
|
||||
const {logger, siprec} = req.locals;
|
||||
|
||||
if (siprec) return next();
|
||||
|
||||
Object.assign(req.locals, {
|
||||
calledNumber: req.calledNumber,
|
||||
callingNumber: req.callingNumber
|
||||
@@ -122,8 +180,7 @@ module.exports = function(srf, logger) {
|
||||
* Given the dialed DID/phone number, retrieve the application to invoke
|
||||
*/
|
||||
async function retrieveApplication(req, res, next) {
|
||||
const logger = req.locals.logger;
|
||||
const {accountInfo, account_sid, rootSpan} = req.locals;
|
||||
const {logger, accountInfo, account_sid, rootSpan} = req.locals;
|
||||
const {span} = rootSpan.startChildSpan('lookupApplication');
|
||||
try {
|
||||
let app;
|
||||
@@ -186,29 +243,33 @@ 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).
|
||||
*/
|
||||
|
||||
/* allow for caching data - when caching treat retrieved data as immutable */
|
||||
const app2 = JAMBONES_MYSQL_REFRESH_TTL ? JSON.parse(JSON.stringify(app)) : app;
|
||||
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';
|
||||
const requestor = new WsRequestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret) ;
|
||||
app2.requestor = requestor;
|
||||
app2.notifier = requestor;
|
||||
app2.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,
|
||||
app2.requestor = new HttpRequestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret);
|
||||
if (app.call_status_hook) app2.notifier = new HttpRequestor(logger, account_sid, app.call_status_hook,
|
||||
accountInfo.account.webhook_secret);
|
||||
else app.notifier = {request: () => {}};
|
||||
else app2.notifier = {request: () => {}};
|
||||
}
|
||||
|
||||
req.locals.application = app;
|
||||
const obj = Object.assign({}, app);
|
||||
delete obj.requestor;
|
||||
delete obj.notifier;
|
||||
req.locals.application = app2;
|
||||
|
||||
// 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}`);
|
||||
const {call_hook, call_status_hook, ...appInfo} = app; // mask sensitive data like user/pass on webhook
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const {requestor, notifier, ...loggable} = appInfo;
|
||||
logger.info({app: loggable}, `retrieved application for incoming call to ${req.locals.calledNumber}`);
|
||||
req.locals.callInfo = new CallInfo({
|
||||
req,
|
||||
app,
|
||||
app: app2,
|
||||
direction: CallDirection.Inbound,
|
||||
traceId: rootSpan.traceId
|
||||
});
|
||||
@@ -225,46 +286,71 @@ module.exports = function(srf, logger) {
|
||||
*/
|
||||
async function invokeWebCallback(req, res, next) {
|
||||
const logger = req.locals.logger;
|
||||
const {rootSpan, application:app} = req.locals;
|
||||
const {rootSpan, siprec, application:app} = req.locals;
|
||||
let span;
|
||||
try {
|
||||
if (app.tasks) {
|
||||
if (app.tasks && !JAMBONES_MYSQL_REFRESH_TTL) {
|
||||
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(['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
|
||||
let json;
|
||||
if (app.app_json) {
|
||||
json = JSON.parse(app.app_json);
|
||||
} else {
|
||||
const params = Object.assign(['POST', 'WS'].includes(app.call_hook.method) ? { sip: req.msg } : {},
|
||||
req.locals.callInfo,
|
||||
{ service_provider_sid: req.locals.service_provider_sid },
|
||||
{
|
||||
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);
|
||||
});
|
||||
logger.debug({ params }, 'sending initial webhook');
|
||||
const obj = rootSpan.startChildSpan('performAppWebhook');
|
||||
span = obj.span;
|
||||
const b3 = rootSpan.getTracingPropagation();
|
||||
const httpHeaders = b3 && { b3 };
|
||||
json = await app.requestor.request('session:new', app.call_hook, params, httpHeaders);
|
||||
}
|
||||
app.tasks = normalizeJambones(logger, json).map((tdata) => makeTask(logger, tdata));
|
||||
span.setAttributes({
|
||||
span?.setAttributes({
|
||||
'http.statusCode': 200,
|
||||
'app.tasks': listTaskNames(app.tasks)
|
||||
});
|
||||
span.end();
|
||||
span?.end();
|
||||
if (0 === app.tasks.length) throw new Error('no application provided');
|
||||
|
||||
if (siprec) {
|
||||
const tasks = app.tasks.filter((t) => AllowedSipRecVerbs.includes(t.name));
|
||||
if (0 === tasks.length) {
|
||||
logger.info({tasks: app.tasks}, 'no valid verbs in app found for an incoming siprec call');
|
||||
throw new Error('invalid verbs for incoming siprec call');
|
||||
}
|
||||
if (tasks.length < app.tasks.length) {
|
||||
logger.info('removing verbs that are not allowed for incoming siprec call');
|
||||
app.tasks = tasks;
|
||||
}
|
||||
}
|
||||
next();
|
||||
} catch (err) {
|
||||
span?.setAttributes({webhookStatus: err.statusCode});
|
||||
span?.end();
|
||||
writeAlerts({
|
||||
account_sid: req.locals.account_sid,
|
||||
target_sid: req.locals.callSid,
|
||||
alert_type: AlertType.INVALID_APP_PAYLOAD,
|
||||
message: `${err?.message}`.trim()
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for parsing application'));
|
||||
logger.info({err}, `Error retrieving or parsing application: ${err?.message}`);
|
||||
res.send(480, {headers: {'X-Reason': err?.message || 'unknown'}});
|
||||
app.requestor.close();
|
||||
@@ -274,6 +360,7 @@ module.exports = function(srf, logger) {
|
||||
return {
|
||||
initLocals,
|
||||
createRootSpan,
|
||||
handleSipRec,
|
||||
getAccountDetails,
|
||||
normalizeNumbers,
|
||||
retrieveApplication,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const {CallDirection, CallStatus} = require('../utils/constants');
|
||||
const parseUri = require('drachtio-srf').parseUri;
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const uuidv4 = require('uuid-random');
|
||||
const {JAMBONES_API_BASE_URL} = require('../config');
|
||||
/**
|
||||
* @classdesc Represents the common information for all calls
|
||||
* that is provided in call status webhooks
|
||||
@@ -11,6 +12,7 @@ class CallInfo {
|
||||
let srf;
|
||||
this.direction = opts.direction;
|
||||
this.traceId = opts.traceId;
|
||||
this.callTerminationBy = undefined;
|
||||
if (opts.req) {
|
||||
const u = opts.req.getParsedHeader('from');
|
||||
const uri = parseUri(u.uri);
|
||||
@@ -32,6 +34,23 @@ class CallInfo {
|
||||
this.callStatus = CallStatus.Trying;
|
||||
this.originatingSipIp = req.get('X-Forwarded-For');
|
||||
this.originatingSipTrunkName = req.get('X-Originating-Carrier');
|
||||
const {siprec} = req.locals;
|
||||
if (siprec) {
|
||||
const caller = parseUri(req.locals.callingNumber);
|
||||
const callee = parseUri(req.locals.calledNumber);
|
||||
this.participants = [
|
||||
{
|
||||
participant: 'caller',
|
||||
uriUser: caller?.user,
|
||||
uriHost: caller?.host
|
||||
},
|
||||
{
|
||||
participant: 'callee',
|
||||
uriUser: callee?.user,
|
||||
uriHost: callee?.host
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
else if (opts.parentCallInfo) {
|
||||
// outbound call that is a child of an existing call
|
||||
@@ -119,7 +138,7 @@ class CallInfo {
|
||||
applicationSid: this.applicationSid,
|
||||
fsSipAddress: this.localSipAddress
|
||||
};
|
||||
['parentCallSid', 'originatingSipIp', 'originatingSipTrunkName'].forEach((prop) => {
|
||||
['parentCallSid', 'originatingSipIp', 'originatingSipTrunkName', 'callTerminationBy'].forEach((prop) => {
|
||||
if (this[prop]) obj[prop] = this[prop];
|
||||
});
|
||||
if (typeof this.duration === 'number') obj.duration = this.duration;
|
||||
@@ -128,8 +147,8 @@ class CallInfo {
|
||||
Object.assign(obj, {customerData: this._customerData});
|
||||
}
|
||||
|
||||
if (process.env.JAMBONES_API_BASE_URL) {
|
||||
Object.assign(obj, {apiBaseUrl: process.env.JAMBONES_API_BASE_URL});
|
||||
if (JAMBONES_API_BASE_URL) {
|
||||
Object.assign(obj, {apiBaseUrl: JAMBONES_API_BASE_URL});
|
||||
}
|
||||
if (this.publicIp) {
|
||||
Object.assign(obj, {fsPublicIp: this.publicIp});
|
||||
|
||||
@@ -6,16 +6,21 @@ const {
|
||||
CallStatus,
|
||||
TaskName,
|
||||
KillReason,
|
||||
RecordState
|
||||
RecordState,
|
||||
AllowedSipRecVerbs
|
||||
} = require('../utils/constants');
|
||||
const moment = require('moment');
|
||||
const assert = require('assert');
|
||||
const sessionTracker = require('./session-tracker');
|
||||
const makeTask = require('../tasks/make_task');
|
||||
const normalizeJambones = require('../utils/normalize-jambones');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const listTaskNames = require('../utils/summarize-tasks');
|
||||
const HttpRequestor = require('../utils/http-requestor');
|
||||
const WsRequestor = require('../utils/ws-requestor');
|
||||
const {
|
||||
JAMBONES_INJECT_CONTENT,
|
||||
AWS_REGION
|
||||
} = require('../config');
|
||||
const BADPRECONDITIONS = 'preconditions not met';
|
||||
const CALLER_CANCELLED_ERR_MSG = 'Response not sent due to unknown transaction';
|
||||
|
||||
@@ -59,9 +64,20 @@ class CallSession extends Emitter {
|
||||
this.notifiedComplete = false;
|
||||
this.rootSpan = rootSpan;
|
||||
|
||||
this._origRecognizerSettings = {
|
||||
vendor: this.application?.speech_recognizer_vendor,
|
||||
language: this.application?.speech_recognizer_language,
|
||||
};
|
||||
this._origSynthesizerSettings = {
|
||||
vendor: this.application?.speech_synthesis_vendor,
|
||||
language: this.application?.speech_synthesis_language,
|
||||
voice: this.application?.speech_synthesis_voice,
|
||||
};
|
||||
|
||||
assert(rootSpan);
|
||||
|
||||
this._recordState = RecordState.RecordingOff;
|
||||
this._notifyEvents = false;
|
||||
|
||||
this.tmpFiles = new Set();
|
||||
|
||||
@@ -72,12 +88,26 @@ class CallSession extends Emitter {
|
||||
|
||||
if (!this.isConfirmCallSession && !this.isSmsCallSession && !this.isAdultingCallSession) {
|
||||
sessionTracker.add(this.callSid, this);
|
||||
|
||||
const {startAmd, stopAmd} = require('../utils/amd-utils')(logger);
|
||||
this.startAmd = startAmd;
|
||||
this.stopAmd = stopAmd;
|
||||
}
|
||||
|
||||
this._pool = srf.locals.dbHelpers.pool;
|
||||
|
||||
const handover = (newRequestor) => {
|
||||
this.logger.info(`handover to new base url ${newRequestor.url}`);
|
||||
this.requestor.removeAllListeners();
|
||||
this.application.requestor = newRequestor;
|
||||
this.requestor.on('command', this._onCommand.bind(this));
|
||||
this.requestor.on('connection-dropped', this._onWsConnectionDropped.bind(this));
|
||||
this.requestor.on('handover', handover.bind(this));
|
||||
};
|
||||
|
||||
this.requestor.on('command', this._onCommand.bind(this));
|
||||
this.requestor.on('connection-dropped', this._onWsConnectionDropped.bind(this));
|
||||
this.requestor.on('handover', handover.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,6 +128,10 @@ class CallSession extends Emitter {
|
||||
return this.callInfo.applicationSid;
|
||||
}
|
||||
|
||||
get callStatus() {
|
||||
return this.callInfo.callStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* SIP call-id for the call
|
||||
*/
|
||||
@@ -224,6 +258,13 @@ class CallSession extends Emitter {
|
||||
return this.constructor.name === 'ConfirmCallSession';
|
||||
}
|
||||
|
||||
/**
|
||||
* returns true if this session is a SipRecCallSession
|
||||
*/
|
||||
get isSipRecCallSession() {
|
||||
return this.constructor.name === 'SipRecCallSession';
|
||||
}
|
||||
|
||||
/**
|
||||
* returns true if this session is a SmsCallSession
|
||||
*/
|
||||
@@ -243,12 +284,72 @@ class CallSession extends Emitter {
|
||||
return this.backgroundGatherTask;
|
||||
}
|
||||
|
||||
get isListenEnabled() {
|
||||
return this.backgroundListenTask;
|
||||
}
|
||||
|
||||
get b3() {
|
||||
return this.rootSpan?.getTracingPropagation();
|
||||
}
|
||||
|
||||
get recordState() { return this._recordState; }
|
||||
|
||||
get notifyEvents() { return this._notifyEvents; }
|
||||
set notifyEvents(notify) { this._notifyEvents = !!notify; }
|
||||
|
||||
set globalSttHints({hints, hintsBoost}) {
|
||||
this._globalSttHints = {hints, hintsBoost};
|
||||
}
|
||||
|
||||
get hasGlobalSttHints() {
|
||||
const {hints = []} = this._globalSttHints || {};
|
||||
return hints.length > 0;
|
||||
}
|
||||
|
||||
get globalSttHints() {
|
||||
return this._globalSttHints;
|
||||
}
|
||||
|
||||
set altLanguages(langs) {
|
||||
this._globalAltLanguages = langs;
|
||||
}
|
||||
|
||||
get hasAltLanguages() {
|
||||
return Array.isArray(this._globalAltLanguages);
|
||||
}
|
||||
|
||||
get altLanguages() {
|
||||
return this._globalAltLanguages;
|
||||
}
|
||||
|
||||
set globalSttPunctuation(punctuate) {
|
||||
this._globalSttPunctuation = punctuate;
|
||||
}
|
||||
|
||||
get globalSttPunctuation() {
|
||||
return this._globalSttPunctuation;
|
||||
}
|
||||
|
||||
hasGlobalSttPunctuation() {
|
||||
return this._globalSttPunctuation !== undefined;
|
||||
}
|
||||
|
||||
resetRecognizer() {
|
||||
this._globalSttHints = undefined;
|
||||
this._globalSttPunctuation = undefined;
|
||||
this._globalAltLanguages = undefined;
|
||||
this.isContinuousAsr = false;
|
||||
this.asrDtmfTerminationDigits = undefined;
|
||||
this.speechRecognizerLanguage = this._origRecognizerSettings.language;
|
||||
this.speechRecognizerVendor = this._origRecognizerSettings.vendor;
|
||||
}
|
||||
|
||||
resetSynthesizer() {
|
||||
this.speechSynthesisLanguage = this._origSynthesizerSettings.language;
|
||||
this.speechSynthesisVendor = this._origSynthesizerSettings.vendor;
|
||||
this.speechSynthesisVoice = this._origSynthesizerSettings.voice;
|
||||
}
|
||||
|
||||
async notifyRecordOptions(opts) {
|
||||
const {action} = opts;
|
||||
this.logger.debug({opts}, 'CallSession:notifyRecordOptions');
|
||||
@@ -394,27 +495,90 @@ class CallSession extends Emitter {
|
||||
}
|
||||
}
|
||||
|
||||
async startBackgroundListen(opts) {
|
||||
if (this.isListenEnabled) {
|
||||
this.logger.info('CallSession:startBackgroundListen - listen is already enabled, ignoring request');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.logger.debug({opts}, 'CallSession:startBackgroundListen');
|
||||
const t = normalizeJambones(this.logger, [opts]);
|
||||
this.backgroundListenTask = makeTask(this.logger, t[0]);
|
||||
const resources = await this._evaluatePreconditions(this.backgroundListenTask);
|
||||
const {span, ctx} = this.rootSpan.startChildSpan(`background-gather:${this.backgroundListenTask.summary}`);
|
||||
this.backgroundListenTask.span = span;
|
||||
this.backgroundListenTask.ctx = ctx;
|
||||
this.backgroundListenTask.exec(this, resources)
|
||||
.then(() => {
|
||||
this.logger.info('CallSession:startBackgroundListen: listen completed');
|
||||
this.backgroundListenTask && this.backgroundListenTask.removeAllListeners();
|
||||
this.backgroundListenTask && this.backgroundListenTask.span.end();
|
||||
this.backgroundListenTask = null;
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
this.logger.info({err}, 'CallSession:startBackgroundListen: listen threw error');
|
||||
this.backgroundListenTask && this.backgroundListenTask.removeAllListeners();
|
||||
this.backgroundListenTask && this.backgroundListenTask.span.end();
|
||||
this.backgroundListenTask = null;
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.info({err, opts}, 'CallSession:startBackgroundListen - Error creating listen task');
|
||||
}
|
||||
}
|
||||
|
||||
async stopBackgroundListen() {
|
||||
try {
|
||||
if (this.backgroundListenTask) {
|
||||
this.backgroundListenTask.removeAllListeners();
|
||||
this.backgroundListenTask.kill().catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'CallSession:stopBackgroundListen - Error stopping listen task');
|
||||
}
|
||||
this.backgroundListenTask = null;
|
||||
}
|
||||
|
||||
async enableBotMode(gather, autoEnable) {
|
||||
try {
|
||||
const t = normalizeJambones(this.logger, [gather]);
|
||||
this.backgroundGatherTask = makeTask(this.logger, t[0]);
|
||||
const task = makeTask(this.logger, t[0]);
|
||||
|
||||
if (this.isBotModeEnabled) {
|
||||
const currInput = this.backgroundGatherTask.input;
|
||||
const newInput = task.input;
|
||||
if (JSON.stringify(currInput) === JSON.stringify(newInput)) {
|
||||
this.logger.info('CallSession:enableBotMode - bot mode currently enabled, ignoring request to start again');
|
||||
return;
|
||||
}
|
||||
else {
|
||||
this.logger.info({currInput, newInput},
|
||||
'CallSession:enableBotMode - restarting background gather to apply new input type');
|
||||
this.backgroundGatherTask.sticky = false;
|
||||
await this.disableBotMode();
|
||||
}
|
||||
}
|
||||
this.backgroundGatherTask = task;
|
||||
this._bargeInEnabled = true;
|
||||
this.backgroundGatherTask
|
||||
.once('dtmf', this._clearTasks.bind(this))
|
||||
.once('vad', this._clearTasks.bind(this))
|
||||
.once('transcription', this._clearTasks.bind(this))
|
||||
.once('timeout', this._clearTasks.bind(this));
|
||||
.once('dtmf', this._clearTasks.bind(this, this.backgroundGatherTask))
|
||||
.once('vad', this._clearTasks.bind(this, this.backgroundGatherTask))
|
||||
.once('transcription', this._clearTasks.bind(this, this.backgroundGatherTask))
|
||||
.once('timeout', this._clearTasks.bind(this, this.backgroundGatherTask));
|
||||
this.logger.info({gather}, 'CallSession:enableBotMode - starting background gather');
|
||||
const resources = await this._evaluatePreconditions(this.backgroundGatherTask);
|
||||
const {span, ctx} = this.rootSpan.startChildSpan(`background-gather:${this.backgroundGatherTask.summary}`);
|
||||
this.backgroundGatherTask.span = span;
|
||||
this.backgroundGatherTask.ctx = ctx;
|
||||
this.backgroundGatherTask.sticky = autoEnable;
|
||||
this.backgroundGatherTask.exec(this, resources)
|
||||
.then(() => {
|
||||
this.logger.info('CallSession:enableBotMode: gather completed');
|
||||
this.backgroundGatherTask && this.backgroundGatherTask.removeAllListeners();
|
||||
this.backgroundGatherTask && this.backgroundGatherTask.span.end();
|
||||
const sticky = this.backgroundGatherTask?.sticky;
|
||||
this.backgroundGatherTask = null;
|
||||
if (autoEnable && !this.callGone && !this._stopping) {
|
||||
if (sticky && !this.callGone && !this._stopping && this._bargeInEnabled) {
|
||||
this.logger.info('CallSession:enableBotMode: restarting background gather');
|
||||
setImmediate(() => this.enableBotMode(gather, true));
|
||||
}
|
||||
@@ -430,11 +594,12 @@ class CallSession extends Emitter {
|
||||
this.logger.info({err, gather}, 'CallSession:enableBotMode - Error creating gather task');
|
||||
}
|
||||
}
|
||||
disableBotMode() {
|
||||
async disableBotMode() {
|
||||
this._bargeInEnabled = false;
|
||||
if (this.backgroundGatherTask) {
|
||||
try {
|
||||
this.backgroundGatherTask.removeAllListeners();
|
||||
this.backgroundGatherTask.kill().catch((err) => {});
|
||||
await this.backgroundGatherTask.kill();
|
||||
} catch (err) {}
|
||||
this.backgroundGatherTask = null;
|
||||
}
|
||||
@@ -491,14 +656,18 @@ class CallSession extends Emitter {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
accessKeyId: credential.access_key_id,
|
||||
secretAccessKey: credential.secret_access_key,
|
||||
region: credential.aws_region || process.env.AWS_REGION
|
||||
region: credential.aws_region || AWS_REGION
|
||||
};
|
||||
}
|
||||
else if ('microsoft' === vendor) {
|
||||
return {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
api_key: credential.api_key,
|
||||
region: credential.region
|
||||
region: credential.region,
|
||||
use_custom_stt: credential.use_custom_stt,
|
||||
custom_stt_endpoint: credential.custom_stt_endpoint,
|
||||
use_custom_tts: credential.use_custom_tts,
|
||||
custom_tts_endpoint: credential.custom_tts_endpoint
|
||||
};
|
||||
}
|
||||
else if ('wellsaid' === vendor) {
|
||||
@@ -507,6 +676,50 @@ class CallSession extends Emitter {
|
||||
api_key: credential.api_key
|
||||
};
|
||||
}
|
||||
else if ('nuance' === vendor) {
|
||||
return {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
client_id: credential.client_id,
|
||||
secret: credential.secret,
|
||||
nuance_tts_uri: credential.nuance_tts_uri,
|
||||
nuance_stt_uri: credential.nuance_stt_uri
|
||||
};
|
||||
}
|
||||
else if ('deepgram' === vendor) {
|
||||
return {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
api_key: credential.api_key
|
||||
};
|
||||
}
|
||||
else if ('soniox' === vendor) {
|
||||
return {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
api_key: credential.api_key
|
||||
};
|
||||
}
|
||||
else if ('ibm' === vendor) {
|
||||
return {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
tts_api_key: credential.tts_api_key,
|
||||
tts_region: credential.tts_region,
|
||||
stt_api_key: credential.stt_api_key,
|
||||
stt_region: credential.stt_region
|
||||
};
|
||||
}
|
||||
else if ('nvidia' === vendor) {
|
||||
return {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
riva_server_uri: credential.riva_server_uri
|
||||
};
|
||||
}
|
||||
else if (vendor.startsWith('custom:')) {
|
||||
return {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
auth_token: credential.auth_token,
|
||||
custom_stt_url: credential.custom_stt_url,
|
||||
custom_tts_url: credential.custom_tts_url
|
||||
};
|
||||
}
|
||||
}
|
||||
else {
|
||||
writeAlerts({
|
||||
@@ -531,16 +744,24 @@ class CallSession extends Emitter {
|
||||
const stackNum = this.stackIdx;
|
||||
const task = this.tasks.shift();
|
||||
this.logger.info(`CallSession:exec starting task #${stackNum}:${taskNum}: ${task.name}`);
|
||||
this._notifyTaskStatus(task, {event: 'starting'});
|
||||
try {
|
||||
const resources = await this._evaluatePreconditions(task);
|
||||
let skip = false;
|
||||
this.currentTask = task;
|
||||
if (TaskName.Gather === task.name && this.isBotModeEnabled) {
|
||||
const timeout = task.timeout;
|
||||
this.logger.info(`CallSession:exec skipping #${stackNum}:${taskNum}: ${task.name}`);
|
||||
this.backgroundGatherTask.updateTimeout(timeout);
|
||||
if (this.backgroundGatherTask.updateTaskInProgress(task) !== false) {
|
||||
this.logger.info(`CallSession:exec skipping #${stackNum}:${taskNum}: ${task.name}`);
|
||||
skip = true;
|
||||
}
|
||||
else {
|
||||
this.logger.info('CallSession:exec disabling bot mode to start gather with new options');
|
||||
await this.disableBotMode();
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (!skip) {
|
||||
const {span, ctx} = this.rootSpan.startChildSpan(`verb:${task.summary}`);
|
||||
span.setAttributes({'verb.summary': task.summary});
|
||||
task.span = span;
|
||||
task.ctx = ctx;
|
||||
await task.exec(this, resources);
|
||||
@@ -548,6 +769,7 @@ class CallSession extends Emitter {
|
||||
}
|
||||
this.currentTask = null;
|
||||
this.logger.info(`CallSession:exec completed task #${stackNum}:${taskNum}: ${task.name}`);
|
||||
this._notifyTaskStatus(task, {event: 'finished'});
|
||||
} catch (err) {
|
||||
task.span?.end();
|
||||
this.currentTask = null;
|
||||
@@ -560,20 +782,23 @@ class CallSession extends Emitter {
|
||||
}
|
||||
}
|
||||
|
||||
if (0 === this.tasks.length && this.hasStableDialog && this.requestor instanceof WsRequestor) {
|
||||
let span;
|
||||
if (0 === this.tasks.length && this.requestor instanceof WsRequestor && !this.callGone) {
|
||||
//let span;
|
||||
try {
|
||||
const {span} = this.rootSpan.startChildSpan('waiting for commands');
|
||||
const {reason, queue, command} = await this._awaitCommandsOrHangup();
|
||||
//const {span} = this.rootSpan.startChildSpan('waiting for commands');
|
||||
//const {reason, queue, command} = await this._awaitCommandsOrHangup();
|
||||
/*
|
||||
span.setAttributes({
|
||||
'completion.reason': reason,
|
||||
'async.request.queue': queue,
|
||||
'async.request.command': command
|
||||
});
|
||||
span.end();
|
||||
if (!this.hasStableDialog || this.callGone) break;
|
||||
*/
|
||||
await this._awaitCommandsOrHangup();
|
||||
if (this.callGone) break;
|
||||
} catch (err) {
|
||||
span.end();
|
||||
//span.end();
|
||||
this.logger.info(err, 'CallSession:exec - error waiting for new commands');
|
||||
break;
|
||||
}
|
||||
@@ -587,12 +812,12 @@ class CallSession extends Emitter {
|
||||
this._onTasksDone();
|
||||
this._clearResources();
|
||||
|
||||
|
||||
if (!this.isConfirmCallSession && !this.isSmsCallSession) sessionTracker.remove(this.callSid);
|
||||
}
|
||||
|
||||
trackTmpFile(path) {
|
||||
// TODO: don't add if its already in the list (should we make it a set?)
|
||||
this.logger.debug(`adding tmp file to track ${path}`);
|
||||
this.tmpFiles.add(path);
|
||||
}
|
||||
|
||||
@@ -904,6 +1129,20 @@ class CallSession extends Emitter {
|
||||
this.logger.debug('CallSession:replaceApplication - ignoring because call is gone');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isSipRecCallSession) {
|
||||
const pruned = tasks.filter((t) => AllowedSipRecVerbs.includes(t.name));
|
||||
if (0 === pruned.length) {
|
||||
this.logger.info({tasks},
|
||||
'CallSession:replaceApplication - only config, transcribe and/or listen allowed on an incoming siprec call');
|
||||
return;
|
||||
}
|
||||
if (pruned.length < tasks.length) {
|
||||
this.logger.info(
|
||||
'CallSession:replaceApplication - removing verbs that are not allowed for incoming siprec call');
|
||||
tasks = pruned;
|
||||
}
|
||||
}
|
||||
this.tasks = tasks;
|
||||
this.taskIdx = 0;
|
||||
this.stackIdx++;
|
||||
@@ -915,14 +1154,32 @@ class CallSession extends Emitter {
|
||||
}
|
||||
}
|
||||
|
||||
kill() {
|
||||
kill(onBackgroundGatherBargein = false) {
|
||||
if (this.isConfirmCallSession) this.logger.debug('CallSession:kill (ConfirmSession)');
|
||||
else this.logger.info('CallSession:kill');
|
||||
if (this.currentTask) {
|
||||
this.currentTask.kill(this);
|
||||
this.currentTask = null;
|
||||
}
|
||||
this.tasks = [];
|
||||
if (onBackgroundGatherBargein) {
|
||||
/* search for a config with bargein disabled */
|
||||
while (this.tasks.length) {
|
||||
const t = this.tasks[0];
|
||||
if (t.name === TaskName.Config && t.bargeIn?.enable === false) {
|
||||
/* found it, clear to that point and remove the disable
|
||||
because we likely already received a partial transcription
|
||||
and we don't want to kill the background gather before we
|
||||
get the full transcription.
|
||||
*/
|
||||
delete t.bargeIn.enable;
|
||||
this._bargeInEnabled = false;
|
||||
this.logger.info('CallSession:kill - found bargein disabled in the stack, clearing to that point');
|
||||
break;
|
||||
}
|
||||
this.tasks.shift();
|
||||
}
|
||||
}
|
||||
else this.tasks = [];
|
||||
this.taskIdx = 0;
|
||||
}
|
||||
|
||||
@@ -935,14 +1192,14 @@ class CallSession extends Emitter {
|
||||
_injectTasks(newTasks) {
|
||||
const gatherPos = this.tasks.map((t) => t.name).indexOf(TaskName.Gather);
|
||||
const currentlyExecutingGather = this.currentTask?.name === TaskName.Gather;
|
||||
|
||||
/*
|
||||
this.logger.debug({
|
||||
currentTaskList: listTaskNames(this.tasks),
|
||||
newContent: listTaskNames(newTasks),
|
||||
currentlyExecutingGather,
|
||||
gatherPos
|
||||
}, 'CallSession:_injectTasks - starting');
|
||||
|
||||
*/
|
||||
const killGather = () => {
|
||||
this.logger.debug('CallSession:_injectTasks - killing current gather because we have new content');
|
||||
this.currentTask.kill(this);
|
||||
@@ -951,10 +1208,11 @@ class CallSession extends Emitter {
|
||||
if (-1 === gatherPos) {
|
||||
/* no gather in the stack simply append tasks */
|
||||
this.tasks.push(...newTasks);
|
||||
/*
|
||||
this.logger.debug({
|
||||
updatedTaskList: listTaskNames(this.tasks)
|
||||
}, 'CallSession:_injectTasks - completed (simple append)');
|
||||
|
||||
*/
|
||||
/* we do need to kill the current gather if we are executing one */
|
||||
if (currentlyExecutingGather) killGather();
|
||||
return;
|
||||
@@ -970,7 +1228,7 @@ class CallSession extends Emitter {
|
||||
}
|
||||
|
||||
_onCommand({msgid, command, call_sid, queueCommand, data}) {
|
||||
this.logger.info({msgid, command, queueCommand}, 'CallSession:_onCommand - received command');
|
||||
this.logger.info({msgid, command, queueCommand, data}, 'CallSession:_onCommand - received command');
|
||||
const resolution = {reason: 'received command', queue: queueCommand, command};
|
||||
switch (command) {
|
||||
case 'redirect':
|
||||
@@ -981,13 +1239,11 @@ class CallSession extends Emitter {
|
||||
this.logger.info({tasks: listTaskNames(t)}, 'CallSession:_onCommand new task list');
|
||||
this.replaceApplication(t);
|
||||
}
|
||||
else if (process.env.JAMBONES_INJECT_CONTENT) {
|
||||
this.logger.debug({tasks: listTaskNames(t)}, 'CallSession:_onCommand - queueing tasks (injecting content)');
|
||||
else if (JAMBONES_INJECT_CONTENT) {
|
||||
this._injectTasks(t);
|
||||
this.logger.info({tasks: listTaskNames(this.tasks)}, 'CallSession:_onCommand - updated task list');
|
||||
}
|
||||
else {
|
||||
this.logger.debug({tasks: listTaskNames(t)}, 'CallSession:_onCommand - queueing tasks');
|
||||
this.tasks.push(...t);
|
||||
this.logger.info({tasks: listTaskNames(this.tasks)}, 'CallSession:_onCommand - updated task list');
|
||||
}
|
||||
@@ -1031,19 +1287,21 @@ class CallSession extends Emitter {
|
||||
this.logger.info(`CallSession:_onCommand - invalid command ${command}`);
|
||||
}
|
||||
if (this.wakeupResolver) {
|
||||
this.logger.debug({resolution}, 'CallSession:_onCommand - got commands, waking up..');
|
||||
//this.logger.debug({resolution}, 'CallSession:_onCommand - got commands, waking up..');
|
||||
this.wakeupResolver(resolution);
|
||||
this.wakeupResolver = null;
|
||||
}
|
||||
/*
|
||||
else {
|
||||
const {span} = this.rootSpan.startChildSpan('async command');
|
||||
const {queue, command} = resolution;
|
||||
const {span} = this.rootSpan.startChildSpan(`recv cmd: ${command}`);
|
||||
span.setAttributes({
|
||||
'async.request.queue': queue,
|
||||
'async.request.command': command
|
||||
});
|
||||
span.end();
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
_onWsConnectionDropped() {
|
||||
@@ -1071,28 +1329,36 @@ class CallSession extends Emitter {
|
||||
* @param {Task} task - task to be executed
|
||||
*/
|
||||
async _evalEndpointPrecondition(task) {
|
||||
this.logger.debug('CallSession:_evalEndpointPrecondition');
|
||||
if (this.callGone) new Error(`${BADPRECONDITIONS}: call gone`);
|
||||
|
||||
if (this.ep) {
|
||||
if (task.earlyMedia === true || this.dlg) return this.ep;
|
||||
const resources = {ep: this.ep};
|
||||
if (task.earlyMedia === true || this.dlg) {
|
||||
return {
|
||||
...resources,
|
||||
...(this.isSipRecCallSession && {ep2: this.ep2})
|
||||
};
|
||||
}
|
||||
|
||||
// we are going from an early media connection to answer
|
||||
await this.propagateAnswer();
|
||||
return this.ep;
|
||||
return {
|
||||
...resources,
|
||||
...(this.isSipRecCallSession && {ep2: this.ep2})
|
||||
};
|
||||
}
|
||||
|
||||
// need to allocate an endpoint
|
||||
try {
|
||||
if (!this.ms) this.ms = this.getMS();
|
||||
const ep = await this.ms.createEndpoint({remoteSdp: this.req.body});
|
||||
const ep = await this.ms.createEndpoint({
|
||||
headers: {
|
||||
'X-Jambones-Call-ID': this.callId,
|
||||
},
|
||||
remoteSdp: this.req.body
|
||||
});
|
||||
//ep.cs = this;
|
||||
this.ep = ep;
|
||||
ep.set({
|
||||
hangup_after_bridge: false,
|
||||
park_after_bridge: true
|
||||
}).catch((err) => this.logger.error({err}, 'Error setting park_after_bridge'));
|
||||
|
||||
this.logger.debug(`allocated endpoint ${ep.uuid}`);
|
||||
|
||||
this.ep.on('destroy', () => {
|
||||
@@ -1102,7 +1368,7 @@ class CallSession extends Emitter {
|
||||
if (this.direction === CallDirection.Inbound) {
|
||||
if (task.earlyMedia && !this.req.finalResponseSent) {
|
||||
this.res.send(183, {body: ep.local.sdp});
|
||||
return ep;
|
||||
return {ep};
|
||||
}
|
||||
this.logger.debug('propogating answer');
|
||||
await this.propagateAnswer();
|
||||
@@ -1111,10 +1377,11 @@ class CallSession extends Emitter {
|
||||
// outbound call TODO
|
||||
}
|
||||
|
||||
return ep;
|
||||
return {ep};
|
||||
} catch (err) {
|
||||
if (err === CALLER_CANCELLED_ERR_MSG) {
|
||||
this.logger.error(err, 'caller canceled quickly before we could respond, ending call');
|
||||
this.callInfo.callTerminationBy = 'caller';
|
||||
this._notifyCallStatusChange({
|
||||
callStatus: CallStatus.NoAnswer,
|
||||
sipStatus: 487,
|
||||
@@ -1136,7 +1403,7 @@ class CallSession extends Emitter {
|
||||
_evalStableCallPrecondition(task) {
|
||||
if (this.callGone) throw new Error(`${BADPRECONDITIONS}: call gone`);
|
||||
if (!this.dlg) throw new Error(`${BADPRECONDITIONS}: call was not answered`);
|
||||
return this.dlg;
|
||||
return {dlg: this.dlg};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1164,7 +1431,6 @@ class CallSession extends Emitter {
|
||||
return;
|
||||
}
|
||||
this.ep = await this.ms.createEndpoint({remoteSdp: this.dlg.remote.sdp});
|
||||
await this.ep.set('hangup_after_bridge', false);
|
||||
|
||||
await this.dlg.modify(this.ep.local.sdp);
|
||||
this.logger.debug('CallSession:replaceEndpoint completed');
|
||||
@@ -1175,7 +1441,7 @@ class CallSession extends Emitter {
|
||||
* Hang up the call and free the media endpoint
|
||||
*/
|
||||
_clearResources() {
|
||||
for (const resource of [this.dlg, this.ep]) {
|
||||
for (const resource of [this.dlg, this.ep, this.ep2]) {
|
||||
if (resource && resource.connected) resource.destroy();
|
||||
}
|
||||
this.dlg = null;
|
||||
@@ -1192,6 +1458,7 @@ class CallSession extends Emitter {
|
||||
}
|
||||
this.tmpFiles.clear();
|
||||
this.requestor && this.requestor.close();
|
||||
this.notifier && this.notifier.close();
|
||||
|
||||
this.rootSpan && this.rootSpan.end();
|
||||
}
|
||||
@@ -1229,7 +1496,8 @@ class CallSession extends Emitter {
|
||||
this.dlg = await this.srf.createUAS(this.req, this.res, {
|
||||
headers: {
|
||||
'X-Trace-ID': this.req.locals.traceId,
|
||||
'X-Call-Sid': this.req.locals.callSid
|
||||
'X-Call-Sid': this.req.locals.callSid,
|
||||
...(this.applicationSid && {'X-Application-Sid': this.applicationSid})
|
||||
},
|
||||
localSdp: this.ep.local.sdp
|
||||
});
|
||||
@@ -1252,9 +1520,15 @@ class CallSession extends Emitter {
|
||||
async _onReinvite(req, res) {
|
||||
try {
|
||||
if (this.ep) {
|
||||
const newSdp = await this.ep.modify(req.body);
|
||||
res.send(200, {body: newSdp});
|
||||
this.logger.info({offer: req.body, answer: newSdp}, 'handling reINVITE');
|
||||
if (this.isSipRecCallSession) {
|
||||
this.logger.info('handling reINVITE for siprec call');
|
||||
res.send(200, {body: this.ep.local.sdp});
|
||||
}
|
||||
else {
|
||||
const newSdp = await this.ep.modify(req.body);
|
||||
res.send(200, {body: newSdp});
|
||||
this.logger.info({offer: req.body, answer: newSdp}, 'handling reINVITE');
|
||||
}
|
||||
}
|
||||
else if (this.currentTask && this.currentTask.name === TaskName.Dial) {
|
||||
this.logger.info('handling reINVITE after media has been released');
|
||||
@@ -1300,7 +1574,6 @@ class CallSession extends Emitter {
|
||||
}
|
||||
if (!this.ep) {
|
||||
this.ep = await this.ms.createEndpoint({remoteSdp: this.req.body});
|
||||
await this.ep.set('hangup_after_bridge', false);
|
||||
}
|
||||
return {ms: this.ms, ep: this.ep};
|
||||
}
|
||||
@@ -1314,7 +1587,7 @@ class CallSession extends Emitter {
|
||||
const pp = this._pool.promise();
|
||||
try {
|
||||
this.logger.info({accountSid: this.accountSid}, 'performQueueWebhook: looking up account');
|
||||
const [r] = await pp.query(sqlRetrieveQueueEventHook, this.accountSid);
|
||||
const [r] = await pp.query(sqlRetrieveQueueEventHook, [this.accountSid]);
|
||||
if (0 === r.length) {
|
||||
this.logger.info({accountSid: this.accountSid}, 'performQueueWebhook: no webhook provisioned');
|
||||
this.queueEventHookRequestor = null;
|
||||
@@ -1383,7 +1656,8 @@ class CallSession extends Emitter {
|
||||
headers: {
|
||||
'Refer-To': referTo,
|
||||
'Referred-By': `sip:${this.srf.locals.localSipAddress}`,
|
||||
'X-Retain-Call-Sid': this.callSid
|
||||
'X-Retain-Call-Sid': this.callSid,
|
||||
'X-Account-Sid': this.accountSid
|
||||
}
|
||||
});
|
||||
if ([200, 202].includes(res.status)) {
|
||||
@@ -1421,8 +1695,9 @@ class CallSession extends Emitter {
|
||||
dlg.connected = false;
|
||||
dlg.destroy = origDestroy;
|
||||
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
||||
this.callInfo.callTerminationBy = 'jambonz';
|
||||
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||
this.logger.debug('CallSession: call terminated by jambones');
|
||||
this.logger.debug('CallSession: call terminated by jambonz');
|
||||
this.rootSpan.setAttributes({'call.termination': 'hangup by jambonz'});
|
||||
origDestroy().catch((err) => this.logger.info({err}, 'CallSession - error destroying dialog'));
|
||||
if (this.wakeupResolver) {
|
||||
@@ -1471,7 +1746,7 @@ class CallSession extends Emitter {
|
||||
* @param {number} sipStatus - current sip status
|
||||
* @param {number} [duration] - duration of a completed call, in seconds
|
||||
*/
|
||||
_notifyCallStatusChange({callStatus, sipStatus, sipReason, duration}) {
|
||||
async _notifyCallStatusChange({callStatus, sipStatus, sipReason, duration}) {
|
||||
if (this.callMoved) return;
|
||||
|
||||
/* race condition: we hang up at the same time as the caller */
|
||||
@@ -1491,7 +1766,7 @@ class CallSession extends Emitter {
|
||||
try {
|
||||
const b3 = this.b3;
|
||||
const httpHeaders = b3 && {b3};
|
||||
this.notifier.request('call:status', this.call_status_hook, this.callInfo.toJSON(), httpHeaders);
|
||||
await this.notifier.request('call:status', this.call_status_hook, this.callInfo.toJSON(), httpHeaders);
|
||||
span.end();
|
||||
} catch (err) {
|
||||
span.end();
|
||||
@@ -1504,6 +1779,25 @@ class CallSession extends Emitter {
|
||||
.catch((err) => this.logger.error(err, 'redis error'));
|
||||
}
|
||||
|
||||
/**
|
||||
* notifyTaskError - only used when websocket connection is used instead of webhooks
|
||||
*/
|
||||
|
||||
_notifyTaskError(obj) {
|
||||
if (this.requestor instanceof WsRequestor) {
|
||||
this.requestor.request('jambonz:error', '/error', obj)
|
||||
.catch((err) => this.logger.debug({err}, 'CallSession:_notifyTaskError - Error sending'));
|
||||
}
|
||||
}
|
||||
|
||||
_notifyTaskStatus(task, evt) {
|
||||
if (this.notifyEvents && this.requestor instanceof WsRequestor) {
|
||||
const obj = {...evt, id: task.id, name: task.name};
|
||||
this.requestor.request('verb:status', '/status', obj)
|
||||
.catch((err) => this.logger.debug({err}, 'CallSession:_notifyTaskStatus - Error sending'));
|
||||
}
|
||||
}
|
||||
|
||||
_awaitCommandsOrHangup() {
|
||||
assert(!this.wakeupResolver);
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -1512,11 +1806,12 @@ class CallSession extends Emitter {
|
||||
});
|
||||
}
|
||||
|
||||
_clearTasks(evt) {
|
||||
if (this.requestor instanceof WsRequestor) {
|
||||
_clearTasks(backgroundGather, evt) {
|
||||
if (this.requestor instanceof WsRequestor && !backgroundGather.cleared) {
|
||||
this.logger.info({evt}, 'CallSession:_clearTasks on event from background gather');
|
||||
try {
|
||||
this.kill();
|
||||
backgroundGather.cleared = true;
|
||||
this.kill(true);
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ class InboundCallSession extends CallSession {
|
||||
|
||||
_onCancel() {
|
||||
this.rootSpan.setAttributes({'call.termination': 'caller abandoned'});
|
||||
this.callInfo.callTerminationBy = 'caller';
|
||||
this._notifyCallStatusChange({
|
||||
callStatus: CallStatus.NoAnswer,
|
||||
sipStatus: 487,
|
||||
@@ -69,6 +70,7 @@ class InboundCallSession extends CallSession {
|
||||
assert(this.dlg.connectTime);
|
||||
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
||||
this.rootSpan.setAttributes({'call.termination': 'hangup by caller'});
|
||||
this.callInfo.callTerminationBy = 'caller';
|
||||
this.emit('callStatusChange', {
|
||||
callStatus: CallStatus.Completed,
|
||||
duration
|
||||
|
||||
@@ -21,6 +21,10 @@ class RestCallSession extends CallSession {
|
||||
});
|
||||
this.req = req;
|
||||
this.ep = ep;
|
||||
// keep restDialTask reference for closing AMD
|
||||
if (tasks.length) {
|
||||
this.restDialTask = tasks[0];
|
||||
}
|
||||
|
||||
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
|
||||
this._notifyCallStatusChange({
|
||||
@@ -44,6 +48,11 @@ class RestCallSession extends CallSession {
|
||||
* This is invoked when the called party hangs up, in order to calculate the call duration.
|
||||
*/
|
||||
_callerHungup() {
|
||||
if (this.restDialTask) {
|
||||
this.logger.info('RestCallSession: releasing AMD');
|
||||
this.restDialTask.turnOffAmd();
|
||||
}
|
||||
this.callInfo.callTerminationBy = 'caller';
|
||||
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
||||
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||
this.logger.debug('RestCallSession: called party hung up');
|
||||
|
||||
86
lib/session/siprec-call-session.js
Normal file
86
lib/session/siprec-call-session.js
Normal file
@@ -0,0 +1,86 @@
|
||||
const InboundCallSession = require('./inbound-call-session');
|
||||
const {createSipRecPayload} = require('../utils/siprec-utils');
|
||||
const {CallStatus} = require('../utils/constants');
|
||||
const {parseSiprecPayload} = require('../utils/siprec-utils');
|
||||
/**
|
||||
* @classdesc Subclass of InboundCallSession. This represents a CallSession that is
|
||||
* established for an inbound SIPREC call.
|
||||
* @extends InboundCallSession
|
||||
*/
|
||||
class SipRecCallSession extends InboundCallSession {
|
||||
constructor(req, res) {
|
||||
super(req, res);
|
||||
|
||||
const {sdp1, sdp2, metadata} = req.locals.siprec;
|
||||
this.sdp1 = sdp1;
|
||||
this.sdp2 = sdp2;
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
async _onReinvite(req, res) {
|
||||
try {
|
||||
this.logger.info(req.payload, 'SipRec Re-INVITE payload');
|
||||
const {sdp1: reSdp1, sdp2: reSdp2, metadata: reMetadata} = await parseSiprecPayload(req, this.logger);
|
||||
this.sdp1 = reSdp1;
|
||||
this.sdp2 = reSdp2;
|
||||
this.metadata = reMetadata;
|
||||
|
||||
if (this.ep && this.ep2) {
|
||||
let remoteSdp = this.sdp1.replace(/sendonly/, 'sendrecv');
|
||||
const newSdp1 = await this.ep.modify(remoteSdp);
|
||||
remoteSdp = this.sdp2.replace(/sendonly/, 'sendrecv');
|
||||
const newSdp2 = await this.ep2.modify(remoteSdp);
|
||||
const combinedSdp = await createSipRecPayload(newSdp1, newSdp2, this.logger);
|
||||
res.send(200, {body: combinedSdp});
|
||||
this.logger.info({offer: req.body, answer: combinedSdp}, 'SipRec handling reINVITE');
|
||||
}
|
||||
else {
|
||||
this.logger.info('got SipRec reINVITE but no endpoint and media has not been released');
|
||||
res.send(488);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'Error handling reinvite');
|
||||
}
|
||||
}
|
||||
|
||||
async answerSipRecCall() {
|
||||
try {
|
||||
this.ms = this.getMS();
|
||||
let remoteSdp = this.sdp1.replace(/sendonly/, 'sendrecv');
|
||||
this.ep = await this.ms.createEndpoint({remoteSdp});
|
||||
//this.logger.debug({remoteSdp, localSdp: this.ep.local.sdp}, 'SipRecCallSession - allocated first endpoint');
|
||||
remoteSdp = this.sdp2.replace(/sendonly/, 'sendrecv');
|
||||
this.ep2 = await this.ms.createEndpoint({remoteSdp});
|
||||
//this.logger.debug({remoteSdp, localSdp: this.ep2.local.sdp}, 'SipRecCallSession - allocated second endpoint');
|
||||
await this.ep.bridge(this.ep2);
|
||||
const combinedSdp = await createSipRecPayload(this.ep.local.sdp, this.ep2.local.sdp, this.logger);
|
||||
/*
|
||||
this.logger.debug({
|
||||
combinedSdp
|
||||
}, 'SipRecCallSession:_answerSipRecCall - created SIPREC payload');
|
||||
*/
|
||||
this.dlg = await this.srf.createUAS(this.req, this.res, {
|
||||
headers: {
|
||||
'Content-Type': 'application/sdp',
|
||||
'X-Trace-ID': this.req.locals.traceId,
|
||||
'X-Call-Sid': this.req.locals.callSid,
|
||||
...(this.applicationSid && {'X-Application-Sid': this.applicationSid})
|
||||
},
|
||||
localSdp: combinedSdp
|
||||
});
|
||||
this.dlg.on('destroy', this._callerHungup.bind(this));
|
||||
this.wrapDialog(this.dlg);
|
||||
this.dlg.callSid = this.callSid;
|
||||
this.emit('callStatusChange', {sipStatus: 200, sipReason: 'OK', callStatus: CallStatus.InProgress});
|
||||
|
||||
this.dlg.on('modify', this._onReinvite.bind(this));
|
||||
this.dlg.on('refer', this._onRefer.bind(this));
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'SipRecCallSession:_answerSipRecCall error:');
|
||||
if (this.res && !this.res.finalResponseSent) this.res.send(500);
|
||||
this._callReleased();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SipRecCallSession;
|
||||
@@ -2,7 +2,7 @@ const Task = require('./task');
|
||||
const Emitter = require('events');
|
||||
const ConfirmCallSession = require('../session/confirm-call-session');
|
||||
const {TaskName, TaskPreconditions, BONG_TONE} = require('../utils/constants');
|
||||
const normalizeJambones = require('../utils/normalize-jambones');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const makeTask = require('./make_task');
|
||||
const bent = require('bent');
|
||||
const assert = require('assert');
|
||||
@@ -72,7 +72,7 @@ class Conference extends Task {
|
||||
get shouldRecord() { return this.record.path; }
|
||||
get isRecording() { return this.recordingInProgress; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
this.ep = ep;
|
||||
const dlg = cs.dlg;
|
||||
@@ -108,6 +108,10 @@ class Conference extends Task {
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
this.logger.info(`Conference:kill ${this.confName}`);
|
||||
if (this._playSession) {
|
||||
this._playSession.kill();
|
||||
this._playSession = null;
|
||||
}
|
||||
this.emitter.emit('kill');
|
||||
await this._doFinalMemberCheck(cs);
|
||||
if (this.ep && this.ep.connected) this.ep.conn.removeAllListeners('esl::event::CUSTOM::*') ;
|
||||
@@ -427,13 +431,19 @@ class Conference extends Task {
|
||||
.catch((err) => this.logger.info({err}, 'Error deafing or undeafing participant'));
|
||||
}
|
||||
|
||||
if (wait_hook) {
|
||||
if (this.wait_hook)
|
||||
delete this.wait_hook.url;
|
||||
this.wait_hook = {url: wait_hook};
|
||||
}
|
||||
|
||||
if (hookOnly && this._playSession) {
|
||||
this._playSession.kill();
|
||||
this._playSession = null;
|
||||
}
|
||||
if (wait_hook && this.conf_hold_status === 'hold') {
|
||||
if (this.wait_hook?.url && this.conf_hold_status === 'hold') {
|
||||
const {dlg} = cs;
|
||||
this._doWaitHookWhileOnHold(cs, dlg, wait_hook);
|
||||
this._doWaitHookWhileOnHold(cs, dlg, this.wait_hook);
|
||||
}
|
||||
else if (this.conf_hold_status !== 'hold' && this._playSession) {
|
||||
this._playSession.kill();
|
||||
@@ -444,7 +454,9 @@ class Conference extends Task {
|
||||
async _doWaitHookWhileOnHold(cs, dlg, wait_hook) {
|
||||
do {
|
||||
try {
|
||||
const tasks = await this._playHook(cs, dlg, wait_hook);
|
||||
let tasks = [];
|
||||
if (wait_hook.url)
|
||||
tasks = await this._playHook(cs, dlg, wait_hook.url);
|
||||
if (0 === tasks.length) break;
|
||||
} catch (err) {
|
||||
if (!this.killed) {
|
||||
@@ -453,7 +465,7 @@ class Conference extends Task {
|
||||
this._playSession = null;
|
||||
break;
|
||||
}
|
||||
} while (!this.killed && this.conf_hold_status !== 'hold');
|
||||
} while (!this.killed && this.conf_hold_status === 'hold');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -541,6 +553,9 @@ class Conference extends Task {
|
||||
}
|
||||
this.logger.debug(`Conference:_playHook: executing ${tasks.length} tasks`);
|
||||
|
||||
/* we might have been killed while off fetching waitHook */
|
||||
if (this.killed) return [];
|
||||
|
||||
if (tasks.length > 0) {
|
||||
this._playSession = new ConfirmCallSession({
|
||||
logger: this.logger,
|
||||
@@ -568,6 +583,10 @@ class Conference extends Task {
|
||||
*/
|
||||
_kicked(cs, dlg) {
|
||||
this.logger.info(`Conference:kicked - I was dropped from conference ${this.confName}, task is complete`);
|
||||
if (this._playSession) {
|
||||
this._playSession.kill();
|
||||
this._playSession = null;
|
||||
}
|
||||
this.replaceEndpointAndEnd(cs);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,15 +4,18 @@ const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
class TaskConfig extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
[
|
||||
'synthesizer',
|
||||
'recognizer',
|
||||
'bargeIn',
|
||||
'record'
|
||||
'record',
|
||||
'listen'
|
||||
].forEach((k) => this[k] = this.data[k] || {});
|
||||
|
||||
if ('notifyEvents' in this.data) {
|
||||
this.notifyEvents = !!this.data.notifyEvents;
|
||||
}
|
||||
|
||||
if (this.bargeIn.enable) {
|
||||
this.gatherOpts = {
|
||||
verb: 'gather',
|
||||
@@ -27,18 +30,31 @@ class TaskConfig extends Task {
|
||||
if (this.bargeIn[k]) this.gatherOpts[k] = this.bargeIn[k];
|
||||
});
|
||||
}
|
||||
|
||||
if (this.data.reset) {
|
||||
if (typeof this.data.reset === 'string') this.data.reset = [this.data.reset];
|
||||
}
|
||||
else this.data.reset = [];
|
||||
|
||||
if (this.bargeIn.sticky) this.autoEnable = true;
|
||||
this.preconditions = this.bargeIn.enable ? TaskPreconditions.Endpoint : TaskPreconditions.None;
|
||||
this.preconditions = (this.bargeIn.enable || this.record?.action || this.listen?.url || this.data.amd) ?
|
||||
TaskPreconditions.Endpoint :
|
||||
TaskPreconditions.None;
|
||||
}
|
||||
|
||||
get name() { return TaskName.Config; }
|
||||
|
||||
get hasSynthesizer() { return Object.keys(this.synthesizer).length; }
|
||||
|
||||
get hasRecognizer() { return Object.keys(this.recognizer).length; }
|
||||
get hasRecording() { return Object.keys(this.record).length; }
|
||||
get hasListen() { return Object.keys(this.listen).length; }
|
||||
|
||||
get summary() {
|
||||
const phrase = [];
|
||||
|
||||
/* reset recognizer and/or synthesizer to default values? */
|
||||
if (this.data.reset.length) phrase.push(`reset ${this.data.reset.join(',')}`);
|
||||
|
||||
if (this.bargeIn.enable) phrase.push('enable barge-in');
|
||||
if (this.hasSynthesizer) {
|
||||
const {vendor:v, language:l, voice} = this.synthesizer;
|
||||
@@ -50,12 +66,41 @@ class TaskConfig extends Task {
|
||||
const s = `{${v},${l}}`;
|
||||
phrase.push(`set recognizer${s}`);
|
||||
}
|
||||
return `${this.name}{${phrase.join(',')}`;
|
||||
if (this.hasRecording) phrase.push(this.record.action);
|
||||
if (this.hasListen) {
|
||||
phrase.push(this.listen.enable ? `listen ${this.listen.url}` : 'stop listen');
|
||||
}
|
||||
if (this.data.amd) phrase.push('enable amd');
|
||||
if (this.notifyEvents) phrase.push(`event notification ${this.notifyEvents ? 'on' : 'off'}`);
|
||||
return `${this.name}{${phrase.join(',')}}`;
|
||||
}
|
||||
|
||||
async exec(cs) {
|
||||
async exec(cs, {ep} = {}) {
|
||||
await super.exec(cs);
|
||||
|
||||
if (this.notifyEvents) {
|
||||
this.logger.debug(`turning event notification ${this.notifyEvents ? 'on' : 'off'}`);
|
||||
cs.notifyEvents = !!this.data.notifyEvents;
|
||||
}
|
||||
|
||||
if (this.data.amd) {
|
||||
this.startAmd = cs.startAmd;
|
||||
this.stopAmd = cs.stopAmd;
|
||||
this.on('amd', this._onAmdEvent.bind(this, cs));
|
||||
|
||||
try {
|
||||
this.ep = ep;
|
||||
this.startAmd(cs, ep, this, this.data.amd);
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Config:exec - Error calling startAmd');
|
||||
}
|
||||
}
|
||||
|
||||
this.data.reset.forEach((k) => {
|
||||
if (k === 'synthesizer') cs.resetSynthesizer();
|
||||
else if (k === 'recognizer') cs.resetRecognizer();
|
||||
});
|
||||
|
||||
if (this.hasSynthesizer) {
|
||||
cs.speechSynthesisVendor = this.synthesizer.vendor !== 'default'
|
||||
? this.synthesizer.vendor
|
||||
@@ -80,13 +125,27 @@ class TaskConfig extends Task {
|
||||
cs.asrTimeout = this.recognizer.asrTimeout;
|
||||
cs.asrDtmfTerminationDigit = this.recognizer.asrDtmfTerminationDigit;
|
||||
}
|
||||
if (Array.isArray(this.recognizer.hints)) {
|
||||
const obj = {hints: this.recognizer.hints};
|
||||
if (typeof this.recognizer.hintsBoost === 'number') {
|
||||
obj.hintsBoost = this.recognizer.hintsBoost;
|
||||
}
|
||||
cs.globalSttHints = obj;
|
||||
}
|
||||
if (Array.isArray(this.recognizer.altLanguages)) {
|
||||
this.logger.info({altLanguages: this.recognizer.altLanguages}, 'Config: updated altLanguages');
|
||||
cs.altLanguages = this.recognizer.altLanguages;
|
||||
}
|
||||
if ('punctuation' in this.recognizer) {
|
||||
cs.globalSttPunctuation = this.recognizer.punctuation;
|
||||
}
|
||||
this.logger.info({
|
||||
recognizer: this.recognizer,
|
||||
isContinuousAsr: cs.isContinuousAsr
|
||||
}, 'Config: updated recognizer');
|
||||
}
|
||||
if ('enable' in this.bargeIn) {
|
||||
if (this.gatherOpts) {
|
||||
if (this.bargeIn.enable === true && this.gatherOpts) {
|
||||
this.gatherOpts.recognizer = this.hasRecognizer ?
|
||||
this.recognizer :
|
||||
{
|
||||
@@ -96,16 +155,43 @@ class TaskConfig extends Task {
|
||||
this.logger.info({opts: this.gatherOpts}, 'Config: enabling bargeIn');
|
||||
cs.enableBotMode(this.gatherOpts, this.autoEnable);
|
||||
}
|
||||
else {
|
||||
else if (this.bargeIn.enable === false) {
|
||||
this.logger.info('Config: disabling bargeIn');
|
||||
cs.disableBotMode();
|
||||
}
|
||||
}
|
||||
if (this.record.action) cs.notifyRecordOptions(this.record);
|
||||
if (this.record.action) {
|
||||
try {
|
||||
await cs.notifyRecordOptions(this.record);
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Config: error starting recording');
|
||||
}
|
||||
}
|
||||
if (this.hasListen) {
|
||||
const {enable, ...opts} = this.listen;
|
||||
if (enable) {
|
||||
this.logger.debug({opts}, 'Config: enabling listen');
|
||||
cs.startBackgroundListen({verb: 'listen', ...opts});
|
||||
} else {
|
||||
this.logger.info('Config: disabling listen');
|
||||
cs.stopBackgroundListen();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
//if (this.ep && this.stopAmd) this.stopAmd(this.ep, this);
|
||||
}
|
||||
|
||||
_onAmdEvent(cs, evt) {
|
||||
this.logger.info({evt}, 'Config:_onAmdEvent');
|
||||
const {actionHook} = this.data.amd;
|
||||
this.performHook(cs, actionHook, evt)
|
||||
.catch((err) => {
|
||||
this.logger.error({err}, 'Config:_onAmdEvent - error calling actionHook');
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ class TaskDequeue extends Task {
|
||||
|
||||
get name() { return TaskName.Dequeue; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
this.ep = ep;
|
||||
this.queueName = `queue:${cs.accountSid}:${this.queueName}`;
|
||||
|
||||
@@ -15,6 +15,7 @@ const DtmfCollector = require('../utils/dtmf-collector');
|
||||
const dbUtils = require('../utils/db-utils');
|
||||
const debug = require('debug')('jambonz:feature-server');
|
||||
const {parseUri} = require('drachtio-srf');
|
||||
const {ANCHOR_MEDIA_ALWAYS} = require('../config');
|
||||
|
||||
function parseDtmfOptions(logger, dtmfCapture) {
|
||||
let parentDtmfCollector, childDtmfCollector;
|
||||
@@ -84,6 +85,7 @@ class TaskDial extends Task {
|
||||
|
||||
this.earlyMedia = this.data.answerOnBridge === true;
|
||||
this.callerId = this.data.callerId;
|
||||
this.callerName = this.data.callerName;
|
||||
this.dialMusic = this.data.dialMusic;
|
||||
this.headers = this.data.headers || {};
|
||||
this.method = this.data.method || 'POST';
|
||||
@@ -134,7 +136,13 @@ class TaskDial extends Task {
|
||||
get name() { return TaskName.Dial; }
|
||||
|
||||
get canReleaseMedia() {
|
||||
return !process.env.ANCHOR_MEDIA_ALWAYS && !this.listenTask && !this.transcribeTask;
|
||||
const keepAnchor = this.data.anchorMedia ||
|
||||
ANCHOR_MEDIA_ALWAYS ||
|
||||
this.listenTask ||
|
||||
this.transcribeTask ||
|
||||
this.startAmd;
|
||||
|
||||
return !keepAnchor;
|
||||
}
|
||||
|
||||
get summary() {
|
||||
@@ -158,6 +166,11 @@ class TaskDial extends Task {
|
||||
async exec(cs) {
|
||||
await super.exec(cs);
|
||||
try {
|
||||
if (this.data.amd) {
|
||||
this.startAmd = cs.startAmd;
|
||||
this.stopAmd = cs.stopAmd;
|
||||
this.on('amd', this._onAmdEvent.bind(this, cs));
|
||||
}
|
||||
if (cs.direction === CallDirection.Inbound) {
|
||||
await this._initializeInbound(cs);
|
||||
}
|
||||
@@ -181,6 +194,11 @@ class TaskDial extends Task {
|
||||
|
||||
async kill(cs, reason) {
|
||||
super.kill(cs);
|
||||
try {
|
||||
if (this.ep && this.ep.amd) this.stopAmd(this.ep, this);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'DialTask:kill - error stopping answering machine detectin');
|
||||
}
|
||||
if (this.dialMusic && this.epOther) {
|
||||
this.epOther.api('uuid_break', this.epOther.uuid)
|
||||
.catch((err) => this.logger.info(err, 'Error killing dialMusic'));
|
||||
@@ -203,8 +221,14 @@ class TaskDial extends Task {
|
||||
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.listenTask) {
|
||||
await this.listenTask.kill(cs);
|
||||
this.listenTask = null;
|
||||
}
|
||||
if (this.transcribeTask) {
|
||||
await this.transcribeTask.kill(cs);
|
||||
this.transcribeTask = null;
|
||||
}
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
@@ -226,10 +250,10 @@ class TaskDial extends Task {
|
||||
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}`);
|
||||
const {span, ctx} = this.startChildSpan(`whisper:${task.summary}`);
|
||||
task.span = span;
|
||||
task.ctx = ctx;
|
||||
await task.exec(cs, callSid === this.callSid ? this.ep : this.epOther);
|
||||
await task.exec(cs, callSid === this.callSid ? {ep: this.ep} : {ep: this.epOther});
|
||||
span.end();
|
||||
}
|
||||
this.logger.debug('Dial:whisper tasks complete');
|
||||
@@ -357,9 +381,8 @@ class TaskDial extends Task {
|
||||
}
|
||||
|
||||
async _initializeInbound(cs) {
|
||||
const ep = await cs._evalEndpointPrecondition(this);
|
||||
const {ep} = await cs._evalEndpointPrecondition(this);
|
||||
this.epOther = ep;
|
||||
debug(`Dial:__initializeInbound allocated ep for incoming call: ${ep.uuid}`);
|
||||
|
||||
/* send outbound legs back to the same SBC (to support static IP feature) */
|
||||
if (!this.proxy) this.proxy = `${cs.req.source_address}:${cs.req.source_port}`;
|
||||
@@ -376,20 +399,25 @@ 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 {lookupCarrier, lookupCarrierByPhoneNumber} = dbUtils(this.logger, cs.srf);
|
||||
const sbcAddress = this.proxy || getSBC();
|
||||
const teamsInfo = {};
|
||||
let fqdn;
|
||||
|
||||
if (!sbcAddress) throw new Error('no SBC found for outbound call');
|
||||
const opts = {
|
||||
headers: req && req.has('X-CID') ? Object.assign(this.headers, {'X-CID': req.get('X-CID')}) : this.headers,
|
||||
proxy: `sip:${sbcAddress}`,
|
||||
callingNumber: this.callerId || req.callingNumber
|
||||
this.headers = {
|
||||
'X-Account-Sid': cs.accountSid,
|
||||
...(req && req.has('X-CID') && {'X-CID': req.get('X-CID')}),
|
||||
...(req && req.has('P-Asserted-Identity') && {'P-Asserted-Identity': req.get('P-Asserted-Identity')}),
|
||||
// Put headers at the end to make sure opt.headers override all default behavior.
|
||||
...this.headers
|
||||
};
|
||||
opts.headers = {
|
||||
...opts.headers,
|
||||
'X-Account-Sid': cs.accountSid
|
||||
|
||||
const opts = {
|
||||
headers: this.headers,
|
||||
proxy: `sip:${sbcAddress}`,
|
||||
callingNumber: this.callerId || req.callingNumber,
|
||||
...(this.callerName && {callingName: this.callerName})
|
||||
};
|
||||
|
||||
const t = this.target.find((t) => t.type === 'teams');
|
||||
@@ -437,6 +465,19 @@ class TaskDial extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* trunk isn't specified,
|
||||
* check if number matches any existing numbers
|
||||
* */
|
||||
if (t.type === 'phone' && !t.trunk) {
|
||||
const voip_carrier_sid = await lookupCarrierByPhoneNumber(req.body.account_sid, t.number);
|
||||
this.logger.info(
|
||||
`Dial:_attemptCalls: selected ${voip_carrier_sid} for requested phone number: ${t.number})`);
|
||||
if (voip_carrier_sid) {
|
||||
opts.headers['X-Requested-Carrier-Sid'] = voip_carrier_sid;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.killed) return;
|
||||
|
||||
const sd = placeCall({
|
||||
@@ -571,6 +612,7 @@ class TaskDial extends Task {
|
||||
* - save the dialog and endpoint
|
||||
* - clock the start time of the call,
|
||||
* - start a max call length timer (optionally)
|
||||
* - start answering machine detection (optionally)
|
||||
* - launch any nested tasks
|
||||
* - and establish a handler to clean up if the called party hangs up
|
||||
*/
|
||||
@@ -611,11 +653,18 @@ class TaskDial extends Task {
|
||||
if (this.parentDtmfCollector) this._installDtmfDetection(cs, cs.dlg);
|
||||
if (this.childDtmfCollector) this._installDtmfDetection(cs, this.dlg);
|
||||
|
||||
if (this.transcribeTask) this.transcribeTask.exec(cs, this.epOther, this.ep);
|
||||
if (this.listenTask) this.listenTask.exec(cs, this.epOther);
|
||||
if (this.transcribeTask) this.transcribeTask.exec(cs, {ep2: this.epOther, ep:this.ep});
|
||||
if (this.listenTask) this.listenTask.exec(cs, {ep: this.epOther});
|
||||
if (this.startAmd) {
|
||||
try {
|
||||
this.startAmd(cs, this.ep, this, this.data.amd);
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Dial:_selectSingleDial - Error calling startAmd');
|
||||
}
|
||||
}
|
||||
|
||||
/* if we can release the media back to the SBC, do so now */
|
||||
if (this.canReleaseMedia) this._releaseMedia(cs, sd);
|
||||
if (this.canReleaseMedia) setTimeout(this._releaseMedia.bind(this, cs, sd), 200);
|
||||
}
|
||||
|
||||
_bridgeEarlyMedia(sd) {
|
||||
@@ -659,6 +708,15 @@ class TaskDial extends Task {
|
||||
this.logger.info({sdp}, 'Dial:handleReinviteAfterMediaReleased - sent reinvite to B leg');
|
||||
res.send(200, {body: sdp});
|
||||
}
|
||||
|
||||
_onAmdEvent(cs, evt) {
|
||||
this.logger.info({evt}, 'Dial:_onAmdEvent');
|
||||
const {actionHook} = this.data.amd;
|
||||
this.performHook(cs, actionHook, evt)
|
||||
.catch((err) => {
|
||||
this.logger.error({err}, 'Dial:_onAmdEvent - error calling actionHook');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskDial;
|
||||
|
||||
@@ -3,7 +3,7 @@ const {TaskName, TaskPreconditions} = require('../../utils/constants');
|
||||
const Intent = require('./intent');
|
||||
const DigitBuffer = require('./digit-buffer');
|
||||
const Transcription = require('./transcription');
|
||||
const normalizeJambones = require('../../utils/normalize-jambones');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
|
||||
class Dialogflow extends Task {
|
||||
constructor(logger, opts) {
|
||||
@@ -64,7 +64,7 @@ class Dialogflow extends Task {
|
||||
|
||||
get name() { return TaskName.Dialogflow; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
|
||||
try {
|
||||
|
||||
@@ -12,7 +12,7 @@ class TaskDtmf extends Task {
|
||||
|
||||
get name() { return TaskName.Dtmf; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
this.ep = ep;
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const Task = require('./task');
|
||||
const Emitter = require('events');
|
||||
const ConfirmCallSession = require('../session/confirm-call-session');
|
||||
const normalizeJambones = require('../utils/normalize-jambones');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const makeTask = require('./make_task');
|
||||
const {TaskName, TaskPreconditions, QueueResults, KillReason} = require('../utils/constants');
|
||||
const bent = require('bent');
|
||||
@@ -37,7 +37,7 @@ class TaskEnqueue extends Task {
|
||||
|
||||
get name() { return TaskName.Enqueue; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
const dlg = cs.dlg;
|
||||
this.queueName = `queue:${cs.accountSid}:${this.queueName}`;
|
||||
|
||||
@@ -3,21 +3,52 @@ const {
|
||||
TaskName,
|
||||
TaskPreconditions,
|
||||
GoogleTranscriptionEvents,
|
||||
NuanceTranscriptionEvents,
|
||||
AwsTranscriptionEvents,
|
||||
AzureTranscriptionEvents
|
||||
AzureTranscriptionEvents,
|
||||
DeepgramTranscriptionEvents,
|
||||
SonioxTranscriptionEvents,
|
||||
IbmTranscriptionEvents,
|
||||
NvidiaTranscriptionEvents,
|
||||
JambonzTranscriptionEvents
|
||||
} = require('../utils/constants');
|
||||
|
||||
const {
|
||||
JAMBONES_GATHER_EARLY_HINTS_MATCH,
|
||||
JAMBONZ_GATHER_EARLY_HINTS_MATCH,
|
||||
JAMBONES_GATHER_CLEAR_GLOBAL_HINTS_ON_EMPTY_HINTS,
|
||||
} = require('../config');
|
||||
const makeTask = require('./make_task');
|
||||
const assert = require('assert');
|
||||
//const GATHER_STABILITY_THRESHOLD = Number(process.env.JAMBONZ_GATHER_STABILITY_THRESHOLD || 0.7);
|
||||
|
||||
const compileTranscripts = (logger, evt, arr) => {
|
||||
if (!Array.isArray(arr) || arr.length === 0) return;
|
||||
let t = '';
|
||||
for (const a of arr) {
|
||||
t += ` ${a.alternatives[0].transcript}`;
|
||||
}
|
||||
t += ` ${evt.alternatives[0].transcript}`;
|
||||
evt.alternatives[0].transcript = t.trim();
|
||||
};
|
||||
|
||||
class TaskGather extends Task {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
const {
|
||||
setChannelVarsForStt,
|
||||
normalizeTranscription,
|
||||
removeSpeechListeners,
|
||||
setSpeechCredentialsAtRuntime,
|
||||
compileSonioxTranscripts
|
||||
} = require('../utils/transcription-utils')(logger);
|
||||
this.setChannelVarsForStt = setChannelVarsForStt;
|
||||
this.normalizeTranscription = normalizeTranscription;
|
||||
this.removeSpeechListeners = removeSpeechListeners;
|
||||
this.compileSonioxTranscripts = compileSonioxTranscripts;
|
||||
|
||||
[
|
||||
'finishOnKey', 'hints', 'input', 'numDigits', 'minDigits', 'maxDigits',
|
||||
'finishOnKey', 'input', 'numDigits', 'minDigits', 'maxDigits',
|
||||
'interDigitTimeout', 'partialResultHook', 'bargein', 'dtmfBargein',
|
||||
'speechTimeout', 'timeout', 'say', 'play'
|
||||
].forEach((k) => this[k] = this.data[k]);
|
||||
@@ -27,53 +58,31 @@ class TaskGather extends Task {
|
||||
|
||||
/* timeout of zero means no timeout */
|
||||
this.timeout = this.timeout === 0 ? 0 : (this.timeout || 15) * 1000;
|
||||
this.interim = this.partialResultHook || this.bargein;
|
||||
this.interim = !!this.partialResultHook || this.bargein || (this.timeout > 0);
|
||||
this.listenDuringPrompt = this.data.listenDuringPrompt === false ? false : true;
|
||||
this.minBargeinWordCount = this.data.minBargeinWordCount || 0;
|
||||
this.minBargeinWordCount = this.data.minBargeinWordCount || 1;
|
||||
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 || [];
|
||||
|
||||
/* let credentials be supplied in the recognizer object at runtime */
|
||||
this.sttCredentials = setSpeechCredentialsAtRuntime(recognizer);
|
||||
|
||||
/* 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 = [];
|
||||
if (Array.isArray(this.data.recognizer.hints) &&
|
||||
0 == this.data.recognizer.hints.length && JAMBONES_GATHER_CLEAR_GLOBAL_HINTS_ON_EMPTY_HINTS) {
|
||||
logger.debug('Gather: an empty hints array was supplied, so we will mask global hints');
|
||||
this.maskGlobalSttHints = true;
|
||||
}
|
||||
this.data.recognizer.hints = this.data.recognizer.hints || [];
|
||||
this.data.recognizer.altLanguages = this.data.recognizer.altLanguages || [];
|
||||
}
|
||||
else this.data.recognizer = {hints: [], altLanguages: []};
|
||||
|
||||
this.digitBuffer = '';
|
||||
this._earlyMedia = this.data.earlyMedia === true;
|
||||
@@ -86,16 +95,24 @@ class TaskGather extends Task {
|
||||
}
|
||||
if (!this.sayTask && !this.playTask) this.listenDuringPrompt = false;
|
||||
|
||||
/* buffer speech for continueous asr */
|
||||
/* buffer speech for continuous asr */
|
||||
this._bufferedTranscripts = [];
|
||||
|
||||
/* buffer for soniox transcripts */
|
||||
this._sonioxTranscripts = [];
|
||||
|
||||
this.parentTask = parentTask;
|
||||
this.partialTranscriptsCount = 0;
|
||||
}
|
||||
|
||||
get name() { return TaskName.Gather; }
|
||||
|
||||
get needsStt() { return this.input.includes('speech'); }
|
||||
|
||||
get wantsSingleUtterance() {
|
||||
return this.data.recognizer?.singleUtterance === true;
|
||||
}
|
||||
|
||||
get earlyMedia() {
|
||||
return (this.sayTask && this.sayTask.earlyMedia) ||
|
||||
(this.playTask && this.playTask.earlyMedia);
|
||||
@@ -116,11 +133,30 @@ class TaskGather extends Task {
|
||||
return s;
|
||||
}
|
||||
|
||||
async exec(cs, ep) {
|
||||
this.logger.debug('Gather:exec');
|
||||
async exec(cs, {ep}) {
|
||||
this.logger.debug({options: this.data}, 'Gather:exec');
|
||||
await super.exec(cs);
|
||||
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
|
||||
const {getNuanceAccessToken, getIbmAccessToken} = cs.srf.locals.dbHelpers;
|
||||
|
||||
if (cs.hasGlobalSttHints && !this.maskGlobalSttHints) {
|
||||
const {hints, hintsBoost} = cs.globalSttHints;
|
||||
const setOfHints = new Set(this.data.recognizer.hints
|
||||
.concat(hints)
|
||||
.filter((h) => typeof h === 'string' && h.length > 0));
|
||||
this.data.recognizer.hints = [...setOfHints];
|
||||
if (!this.data.recognizer.hintsBoost && hintsBoost) this.data.recognizer.hintsBoost = hintsBoost;
|
||||
this.logger.debug({hints: this.data.recognizer.hints, hintsBoost: this.data.recognizer.hintsBoost},
|
||||
'Gather:exec - applying global sttHints');
|
||||
}
|
||||
if (cs.hasAltLanguages) {
|
||||
this.data.recognizer.altLanguages = this.data.recognizer.altLanguages.concat(cs.altLanguages);
|
||||
this.logger.debug({altLanguages: this.altLanguages},
|
||||
'Gather:exec - applying altLanguages');
|
||||
}
|
||||
if (cs.hasGlobalSttPunctuation && !this.data.recognizer.punctuation) {
|
||||
this.data.recognizer.punctuation = cs.globalSttPunctuation;
|
||||
}
|
||||
if (!this.isContinuousAsr && cs.isContinuousAsr) {
|
||||
this.isContinuousAsr = true;
|
||||
this.asrTimeout = cs.asrTimeout * 1000;
|
||||
@@ -130,10 +166,28 @@ class TaskGather extends Task {
|
||||
asrDtmfTerminationDigit: this.asrDtmfTerminationDigit
|
||||
}, 'Gather:exec - enabling continuous ASR since it is turned on for the session');
|
||||
}
|
||||
|
||||
if ((JAMBONZ_GATHER_EARLY_HINTS_MATCH || JAMBONES_GATHER_EARLY_HINTS_MATCH) && this.needsStt &&
|
||||
!this.isContinuousAsr &&
|
||||
this.data.recognizer?.hints?.length > 0 && this.data.recognizer?.hints?.length <= 10) {
|
||||
this.earlyHintsMatch = true;
|
||||
this.interim = true;
|
||||
this.logger.debug('Gather:exec - early hints match enabled');
|
||||
}
|
||||
|
||||
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 ('default' === this.vendor || !this.vendor) {
|
||||
this.vendor = cs.speechRecognizerVendor;
|
||||
if (this.data.recognizer) this.data.recognizer.vendor = this.vendor;
|
||||
}
|
||||
if ('default' === this.language || !this.language) {
|
||||
this.language = cs.speechRecognizerLanguage;
|
||||
if (this.data.recognizer) this.data.recognizer.language = this.language;
|
||||
}
|
||||
if (!this.data.recognizer.vendor) {
|
||||
this.data.recognizer.vendor = this.vendor;
|
||||
}
|
||||
if (this.needsStt && !this.sttCredentials) this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt');
|
||||
if (this.needsStt && !this.sttCredentials) {
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
this.logger.info(`TaskGather:exec - ERROR stt using ${this.vendor} requested but creds not supplied`);
|
||||
@@ -142,20 +196,45 @@ class TaskGather extends Task {
|
||||
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 speech-to-text service credentials for ${this.vendor} have been configured`);
|
||||
// Notify application that STT vender is wrong.
|
||||
this.notifyError({
|
||||
msg: 'ASR error',
|
||||
details: `No speech-to-text service credentials for ${this.vendor} have been configured`
|
||||
});
|
||||
this.notifyTaskDone();
|
||||
throw new Error(`No speech-to-text service credentials for ${this.vendor} have been configured`);
|
||||
}
|
||||
|
||||
if (this.vendor === 'nuance' && this.sttCredentials.client_id) {
|
||||
/* get nuance access token */
|
||||
const {client_id, secret} = this.sttCredentials;
|
||||
const {access_token, servedFromCache} = await getNuanceAccessToken(client_id, secret, 'asr tts');
|
||||
this.logger.debug({client_id}, `Gather:exec - got nuance access token ${servedFromCache ? 'from cache' : ''}`);
|
||||
this.sttCredentials = {...this.sttCredentials, access_token};
|
||||
}
|
||||
else if (this.vendor == 'ibm' && this.sttCredentials.stt_api_key) {
|
||||
/* get ibm access token */
|
||||
const {stt_api_key, stt_region} = this.sttCredentials;
|
||||
const {access_token, servedFromCache} = await getIbmAccessToken(stt_api_key);
|
||||
this.logger.debug({stt_api_key}, `Gather:exec - got ibm access token ${servedFromCache ? 'from cache' : ''}`);
|
||||
this.sttCredentials = {...this.sttCredentials, access_token, stt_region};
|
||||
}
|
||||
const startListening = (cs, ep) => {
|
||||
this._startTimer();
|
||||
if (this.isContinuousAsr && 0 === this.timeout) this._startAsrTimer();
|
||||
if (this.input.includes('speech') && !this.listenDuringPrompt) {
|
||||
this._initSpeech(cs, ep)
|
||||
.then(() => {
|
||||
if (this.killed) {
|
||||
this.logger.info('Gather:exec - task was quickly killed so do not transcribe');
|
||||
return;
|
||||
}
|
||||
this._startTranscribing(ep);
|
||||
return updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((err) => {
|
||||
this.logger.error({err}, 'error in initSpeech');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -164,27 +243,49 @@ class TaskGather extends Task {
|
||||
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.exec(cs, {ep}); // kicked off, _not_ waiting for it to complete
|
||||
this.sayTask.on('playDone', (err) => {
|
||||
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);
|
||||
if (!this.killed) {
|
||||
startListening(cs, ep);
|
||||
if (this.input.includes('speech') && this.vendor === 'nuance' && this.listenDuringPrompt) {
|
||||
this.logger.debug('Gather:exec - starting transcription timers after say completes');
|
||||
ep.startTranscriptionTimers((err) => {
|
||||
if (err) this.logger.error({err}, 'Gather:exec - error starting transcription timers');
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
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.exec(cs, {ep}); // kicked off, _not_ waiting for it to complete
|
||||
this.playTask.on('playDone', (err) => {
|
||||
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);
|
||||
if (!this.killed) {
|
||||
startListening(cs, ep);
|
||||
if (this.input.includes('speech') && this.vendor === 'nuance' && this.listenDuringPrompt) {
|
||||
this.logger.debug('Gather:exec - starting transcription timers after play completes');
|
||||
ep.startTranscriptionTimers((err) => {
|
||||
if (err) this.logger.error({err}, 'Gather:exec - error starting transcription timers');
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
else startListening(cs, ep);
|
||||
else {
|
||||
if (this.killed) {
|
||||
this.logger.info('Gather:exec - task was immediately killed so do not transcribe');
|
||||
return;
|
||||
}
|
||||
startListening(cs, ep);
|
||||
}
|
||||
|
||||
if (this.input.includes('speech') && this.listenDuringPrompt) {
|
||||
await this._initSpeech(cs, ep);
|
||||
@@ -201,14 +302,7 @@ class TaskGather extends Task {
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'TaskGather:exec error');
|
||||
}
|
||||
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);
|
||||
this.removeSpeechListeners(ep);
|
||||
}
|
||||
|
||||
kill(cs) {
|
||||
@@ -216,15 +310,21 @@ class TaskGather extends Task {
|
||||
this._killAudio(cs);
|
||||
this.ep.removeAllListeners('dtmf');
|
||||
clearTimeout(this.interDigitTimer);
|
||||
this._clearAsrTimer();
|
||||
this.playTask?.span.end();
|
||||
this.sayTask?.span.end();
|
||||
this._resolve('killed');
|
||||
}
|
||||
|
||||
updateTimeout(timeout) {
|
||||
this.logger.info(`TaskGather:updateTimeout - updating timeout to ${timeout}`);
|
||||
updateTaskInProgress(opts) {
|
||||
if (!this.needsStt && opts.input.includes('speech')) {
|
||||
this.logger.info('TaskGather:updateTaskInProgress - adding speech to a background gather');
|
||||
return false; // this needs be handled by killing the background gather and starting a new one
|
||||
}
|
||||
const {timeout} = opts;
|
||||
this.timeout = timeout;
|
||||
this._startTimer();
|
||||
return true;
|
||||
}
|
||||
|
||||
_onDtmf(cs, ep, evt) {
|
||||
@@ -263,90 +363,101 @@ class TaskGather extends Task {
|
||||
}
|
||||
|
||||
async _initSpeech(cs, ep) {
|
||||
const opts = {};
|
||||
const opts = this.setChannelVarsForStt(this, this.sttCredentials, this.data.recognizer);
|
||||
switch (this.vendor) {
|
||||
case 'google':
|
||||
this.bugname = 'google_transcribe';
|
||||
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));
|
||||
break;
|
||||
|
||||
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;
|
||||
}
|
||||
case 'aws':
|
||||
case 'polly':
|
||||
this.bugname = 'aws_transcribe';
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep));
|
||||
break;
|
||||
case 'microsoft':
|
||||
this.bugname = 'azure_transcribe';
|
||||
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));
|
||||
break;
|
||||
case 'nuance':
|
||||
this.bugname = 'nuance_transcribe';
|
||||
ep.addCustomEventListener(NuanceTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(NuanceTranscriptionEvents.StartOfSpeech,
|
||||
this._onStartOfSpeech.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(NuanceTranscriptionEvents.TranscriptionComplete,
|
||||
this._onTranscriptionComplete.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(NuanceTranscriptionEvents.VadDetected,
|
||||
this._onVadDetected.bind(this, cs, ep));
|
||||
|
||||
if ('google' === this.vendor) {
|
||||
if (this.sttCredentials) opts.GOOGLE_APPLICATION_CREDENTIALS = JSON.stringify(this.sttCredentials.credentials);
|
||||
[
|
||||
['enhancedModel', 'GOOGLE_SPEECH_USE_ENHANCED'],
|
||||
['separateRecognitionPerChannel', 'GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL'],
|
||||
['profanityFilter', 'GOOGLE_SPEECH_PROFANITY_FILTER'],
|
||||
['punctuation', 'GOOGLE_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION'],
|
||||
['words', 'GOOGLE_SPEECH_ENABLE_WORD_TIME_OFFSETS'],
|
||||
['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 (typeof this.hintsBoost === 'number') {
|
||||
opts.GOOGLE_SPEECH_HINTS_BOOST = this.hintsBoost;
|
||||
/* stall timers until prompt finishes playing */
|
||||
if ((this.sayTask || this.playTask) && this.listenDuringPrompt) {
|
||||
opts.NUANCE_STALL_TIMERS = 1;
|
||||
}
|
||||
}
|
||||
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 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';
|
||||
}
|
||||
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;
|
||||
break;
|
||||
|
||||
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));
|
||||
case 'deepgram':
|
||||
this.bugname = 'deepgram_transcribe';
|
||||
ep.addCustomEventListener(DeepgramTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(DeepgramTranscriptionEvents.Connect, this._onDeepgramConnect.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(DeepgramTranscriptionEvents.ConnectFailure,
|
||||
this._onDeepGramConnectFailure.bind(this, cs, ep));
|
||||
break;
|
||||
|
||||
case 'soniox':
|
||||
this.bugname = 'soniox_transcribe';
|
||||
ep.addCustomEventListener(SonioxTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
break;
|
||||
|
||||
case 'ibm':
|
||||
this.bugname = 'ibm_transcribe';
|
||||
ep.addCustomEventListener(IbmTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(IbmTranscriptionEvents.Connect, this._onIbmConnect.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(IbmTranscriptionEvents.ConnectFailure,
|
||||
this._onIbmConnectFailure.bind(this, cs, ep));
|
||||
break;
|
||||
|
||||
case 'nvidia':
|
||||
this.bugname = 'nvidia_transcribe';
|
||||
ep.addCustomEventListener(NvidiaTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(NvidiaTranscriptionEvents.StartOfSpeech,
|
||||
this._onStartOfSpeech.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(NvidiaTranscriptionEvents.TranscriptionComplete,
|
||||
this._onTranscriptionComplete.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(NvidiaTranscriptionEvents.VadDetected,
|
||||
this._onVadDetected.bind(this, cs, ep));
|
||||
|
||||
/* I think nvidia has this (??) - stall timers until prompt finishes playing */
|
||||
if ((this.sayTask || this.playTask) && this.listenDuringPrompt) {
|
||||
opts.NVIDIA_STALL_TIMERS = 1;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
if (this.vendor.startsWith('custom:')) {
|
||||
this.bugname = `${this.vendor}_transcribe`;
|
||||
ep.addCustomEventListener(JambonzTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(JambonzTranscriptionEvents.Connect, this._onJambonzConnect.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(JambonzTranscriptionEvents.ConnectFailure,
|
||||
this._onJambonzConnectFailure.bind(this, cs, ep));
|
||||
break;
|
||||
}
|
||||
else {
|
||||
this.notifyError({ msg: 'ASR error', details:`Invalid vendor ${this.vendor}`});
|
||||
this.notifyTaskDone();
|
||||
throw new Error(`Invalid vendor ${this.vendor}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* common handler for all stt engine errors */
|
||||
ep.addCustomEventListener(JambonzTranscriptionEvents.Error, this._onJambonzError.bind(this, cs, ep));
|
||||
await ep.set(opts)
|
||||
.catch((err) => this.logger.info(err, 'Error setting channel variables'));
|
||||
}
|
||||
@@ -355,12 +466,14 @@ class TaskGather extends Task {
|
||||
this.logger.debug({
|
||||
vendor: this.vendor,
|
||||
locale: this.language,
|
||||
interim: this.interim
|
||||
interim: this.interim,
|
||||
bugname: this.bugname
|
||||
}, 'Gather:_startTranscribing');
|
||||
ep.startTranscription({
|
||||
vendor: this.vendor,
|
||||
locale: this.language,
|
||||
interim: this.interim,
|
||||
bugname: this.bugname,
|
||||
}).catch((err) => {
|
||||
const {writeAlerts, AlertType} = this.cs.srf.locals;
|
||||
this.logger.error(err, 'TaskGather:_startTranscribing error');
|
||||
@@ -440,44 +553,61 @@ class TaskGather extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
_onTranscription(cs, ep, evt) {
|
||||
if ('aws' === this.vendor && Array.isArray(evt) && evt.length > 0) evt = evt[0];
|
||||
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
|
||||
}
|
||||
]
|
||||
};
|
||||
_onTranscription(cs, ep, evt, fsEvent) {
|
||||
// make sure this is not a transcript from answering machine detection
|
||||
const bugname = fsEvent.getHeader('media-bugname');
|
||||
const finished = fsEvent.getHeader('transcription-session-finished');
|
||||
this.logger.debug({evt, bugname, finished}, `Gather:_onTranscription for vendor ${this.vendor}`);
|
||||
if (bugname && this.bugname !== bugname) return;
|
||||
|
||||
if (this.vendor === 'ibm') {
|
||||
if (evt?.state === 'listening') return;
|
||||
}
|
||||
|
||||
evt = this.normalizeTranscription(evt, this.vendor, 1, this.language);
|
||||
if (evt.alternatives.length === 0) {
|
||||
this.logger.info({evt}, 'TaskGather:_onTranscription - got empty transcript, continue listening');
|
||||
return;
|
||||
}
|
||||
|
||||
/* fast path: our first partial transcript exactly matches an early hint */
|
||||
if (this.earlyHintsMatch && evt.is_final === false && this.partialTranscriptsCount++ === 0) {
|
||||
const transcript = evt.alternatives[0].transcript?.toLowerCase();
|
||||
const hints = this.data.recognizer?.hints || [];
|
||||
if (hints.find((h) => h.toLowerCase() === transcript)) {
|
||||
this.logger.debug({evt}, 'Gather:_onTranscription: early hint match');
|
||||
this._resolve('speech', evt);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/* count words for bargein feature */
|
||||
const words = evt.alternatives[0]?.transcript.split(' ').length;
|
||||
const bufferedWords = this._sonioxTranscripts.length +
|
||||
this._bufferedTranscripts.reduce((count, e) => count + e.alternatives[0]?.transcript.split(' ').length, 0);
|
||||
|
||||
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 (finished === 'true' && ['microsoft', 'deepgram'].includes(this.vendor)) {
|
||||
this.logger.debug({evt}, 'TaskGather:_onTranscription - got empty transcript from old gather, disregarding');
|
||||
}
|
||||
else {
|
||||
this.logger.info({evt}, 'TaskGather:_onTranscription - got empty transcript, continue listening');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isContinuousAsr) {
|
||||
/* append the transcript and start listening again for asrTimeout */
|
||||
const t = evt.alternatives[0].transcript;
|
||||
if (t) {
|
||||
/* remove trailing punctuation */
|
||||
if (/[,;:\.!\?]$/.test(t)) {
|
||||
this.logger.debug('TaskGather:_onTranscription - removing trailing punctuation');
|
||||
evt.alternatives[0].transcript = t.slice(0, -1);
|
||||
}
|
||||
else this.logger.debug({t}, 'TaskGather:_onTranscription - no trailing punctuation');
|
||||
}
|
||||
this.logger.info({evt}, 'TaskGather:_onTranscription - got transcript during continous asr');
|
||||
this._bufferedTranscripts.push(evt);
|
||||
this._clearTimer();
|
||||
@@ -486,9 +616,28 @@ class TaskGather extends Task {
|
||||
return this._resolve(this._bufferedTranscripts.length > 0 ? 'speech' : 'timeout');
|
||||
}
|
||||
this._startAsrTimer();
|
||||
return this._startTranscribing(ep);
|
||||
|
||||
/* some STT engines will keep listening after a final response, so no need to restart */
|
||||
if (!['soniox', 'aws', 'microsoft', 'deepgram'].includes(this.vendor)) this._startTranscribing(ep);
|
||||
}
|
||||
else {
|
||||
if (this.bargein && (words + bufferedWords) < this.minBargeinWordCount) {
|
||||
this.logger.debug({evt, words, bufferedWords},
|
||||
'TaskGather:_onTranscription - final transcript but < min barge words');
|
||||
this._bufferedTranscripts.push(evt);
|
||||
this._startTranscribing(ep);
|
||||
return;
|
||||
}
|
||||
else {
|
||||
if (this.vendor === 'soniox') {
|
||||
/* compile transcripts into one */
|
||||
this._sonioxTranscripts.push(evt.vendor.finalWords);
|
||||
evt = this.compileSonioxTranscripts(this._sonioxTranscripts, 1, this.language);
|
||||
this._sonioxTranscripts = [];
|
||||
}
|
||||
this._resolve('speech', evt);
|
||||
}
|
||||
}
|
||||
else this._resolve('speech', evt);
|
||||
}
|
||||
else {
|
||||
/* google has a measure of stability:
|
||||
@@ -496,9 +645,9 @@ class TaskGather extends Task {
|
||||
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) {
|
||||
this._clearTimer();
|
||||
this._startTimer();
|
||||
if (this.bargein && (words + bufferedWords) >= this.minBargeinWordCount) {
|
||||
if (!this.playComplete) {
|
||||
this.logger.debug({transcript: evt.alternatives[0].transcript}, 'killing audio due to speech');
|
||||
this.emit('vad');
|
||||
@@ -508,9 +657,16 @@ class TaskGather extends Task {
|
||||
if (this.partialResultHook) {
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
this.cs.requestor.request(this.partialResultHook, Object.assign({speech: evt},
|
||||
this.cs.requestor.request('verb:hook', this.partialResultHook, Object.assign({speech: evt},
|
||||
this.cs.callInfo, httpHeaders));
|
||||
}
|
||||
if (this.vendor === 'soniox') {
|
||||
this._clearTimer();
|
||||
if (evt.vendor.finalWords.length) {
|
||||
this.logger.debug({evt}, 'TaskGather:_onTranscription - buffering soniox transcript');
|
||||
this._sonioxTranscripts.push(evt.vendor.finalWords);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_onEndOfUtterance(cs, ep) {
|
||||
@@ -519,11 +675,102 @@ class TaskGather extends Task {
|
||||
this._killAudio(cs);
|
||||
}
|
||||
|
||||
if (!this.resolved && !this.killed) {
|
||||
/**
|
||||
* By default, Gather asks google for multiple utterances.
|
||||
* The reason is that we can sometimes get an 'end_of_utterance' event without
|
||||
* getting a transcription. This can happen if someone coughs or mumbles.
|
||||
* For that reason don't ask for a single utterance and we'll terminate the transcribe operation
|
||||
* once we get a final transcript.
|
||||
* However, if the usr has specified a singleUtterance, then we need to restart here
|
||||
* since we dont have a final transcript yet.
|
||||
*/
|
||||
if (!this.resolved && !this.killed && !this._bufferedTranscripts.length && this.wantsSingleUtterance) {
|
||||
this._startTranscribing(ep);
|
||||
}
|
||||
}
|
||||
|
||||
_onStartOfSpeech(cs, ep) {
|
||||
this.logger.debug('TaskGather:_onStartOfSpeech');
|
||||
if (this.bargein) {
|
||||
this._killAudio(cs);
|
||||
}
|
||||
}
|
||||
_onTranscriptionComplete(cs, ep) {
|
||||
this.logger.debug('TaskGather:_onTranscriptionComplete');
|
||||
}
|
||||
_onDeepgramConnect(_cs, _ep) {
|
||||
this.logger.debug('TaskGather:_onDeepgramConnect');
|
||||
}
|
||||
_onJambonzConnect(_cs, _ep) {
|
||||
this.logger.debug('TaskGather:_onJambonzConnect');
|
||||
}
|
||||
_onJambonzError(cs, _ep, evt) {
|
||||
this.logger.info({evt}, 'TaskGather:_onJambonzError');
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
|
||||
if (this.vendor === 'nuance') {
|
||||
const {code, error} = evt;
|
||||
if (code === 404 && error === 'No speech') return this._resolve('timeout');
|
||||
if (code === 413 && error === 'Too much speech') return this._resolve('timeout');
|
||||
}
|
||||
this.logger.info({evt}, 'TaskGather:_onJambonzError');
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.STT_FAILURE,
|
||||
message: `Custom speech vendor ${this.vendor} error: ${evt.error}`,
|
||||
vendor: this.vendor,
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for jambonz custom connection failure'));
|
||||
this.notifyError({msg: 'ASR error', details:`Custom speech vendor ${this.vendor} error: ${evt.error}`});
|
||||
}
|
||||
|
||||
_onDeepGramConnectFailure(cs, _ep, evt) {
|
||||
const {reason} = evt;
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
this.logger.info({evt}, 'TaskGather:_onDeepgramConnectFailure');
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.STT_FAILURE,
|
||||
message: `Failed connecting to Deepgram speech recognizer: ${reason}`,
|
||||
vendor: 'deepgram',
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for deepgram connection failure'));
|
||||
this.notifyError({msg: 'ASR error', details:`Failed connecting to speech vendor deepgram: ${reason}`});
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
_onJambonzConnectFailure(cs, _ep, evt) {
|
||||
const {reason} = evt;
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
this.logger.info({evt}, 'TaskGather:_onJambonzConnectFailure');
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.STT_FAILURE,
|
||||
message: `Failed connecting to ${this.vendor} speech recognizer: ${reason}`,
|
||||
vendor: this.vendor,
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for jambonz custom connection failure'));
|
||||
this.notifyError({msg: 'ASR error', details:`Failed connecting to speech vendor ${this.vendor}: ${reason}`});
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
_onIbmConnect(_cs, _ep) {
|
||||
this.logger.debug('TaskGather:_onIbmConnect');
|
||||
}
|
||||
|
||||
_onIbmConnectFailure(cs, _ep, evt) {
|
||||
const {reason} = evt;
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
this.logger.info({evt}, 'TaskGather:_onIbmConnectFailure');
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.STT_FAILURE,
|
||||
message: `Failed connecting to IBM watson speech recognizer: ${reason}`,
|
||||
vendor: 'ibm',
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for IBM connection failure'));
|
||||
this.notifyError({msg: 'ASR error', details:`Failed connecting to speech vendor IBM: ${reason}`});
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
_onIbmError(cs, _ep, evt) {
|
||||
this.logger.info({evt}, 'TaskGather:_onIbmError'); }
|
||||
|
||||
_onVadDetected(cs, ep) {
|
||||
if (this.bargein && this.minBargeinWordCount === 0) {
|
||||
this.logger.debug('TaskGather:_onVadDetected');
|
||||
@@ -532,10 +779,17 @@ class TaskGather extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
_onNoSpeechDetected(cs, ep) {
|
||||
_onNoSpeechDetected(cs, ep, evt, fsEvent) {
|
||||
if (!this.callSession.callGone && !this.killed) {
|
||||
this.logger.debug('TaskGather:_onNoSpeechDetected - listen again');
|
||||
return this._startTranscribing(ep);
|
||||
const finished = fsEvent.getHeader('transcription-session-finished');
|
||||
if (this.vendor === 'microsoft' && finished === 'true') {
|
||||
this.logger.debug('TaskGather:_onNoSpeechDetected for old gather, ignoring');
|
||||
}
|
||||
else {
|
||||
this.logger.debug('TaskGather:_onNoSpeechDetected - listen again');
|
||||
this._startTranscribing(ep);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,6 +798,10 @@ class TaskGather extends Task {
|
||||
if (this.resolved) return;
|
||||
|
||||
this.resolved = true;
|
||||
// Clear dtmf event
|
||||
if (this.dtmfBargein) {
|
||||
this.ep.removeAllListeners('dtmf');
|
||||
}
|
||||
clearTimeout(this.interDigitTimer);
|
||||
this._clearTimer();
|
||||
|
||||
@@ -554,9 +812,13 @@ class TaskGather extends Task {
|
||||
};
|
||||
this.logger.debug({evt}, 'TaskGather:resolve continuous asr');
|
||||
}
|
||||
else if (!this.isContinuousAsr && reason.startsWith('speech') && this._bufferedTranscripts.length) {
|
||||
compileTranscripts(this.logger, evt, this._bufferedTranscripts);
|
||||
this.logger.debug({evt}, 'TaskGather:resolve buffered results');
|
||||
}
|
||||
|
||||
this.span.setAttributes({'stt.resolve': reason, 'stt.result': JSON.stringify(evt)});
|
||||
if (this.ep && this.ep.connected) {
|
||||
if (this.needsStt && this.ep && this.ep.connected) {
|
||||
this.ep.stopTranscription({vendor: this.vendor})
|
||||
.catch((err) => this.logger.error({err}, 'Error stopping transcription'));
|
||||
}
|
||||
|
||||
@@ -14,10 +14,11 @@ class TaskHangup extends Task {
|
||||
/**
|
||||
* Hangup the call
|
||||
*/
|
||||
async exec(cs, dlg) {
|
||||
async exec(cs, {dlg}) {
|
||||
await super.exec(cs);
|
||||
try {
|
||||
await dlg.destroy({headers: this.headers});
|
||||
cs._callReleased();
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'TaskHangup:exec - Error hanging up call');
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ class TaskLeave extends Task {
|
||||
|
||||
get name() { return TaskName.Leave; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
await this.awaitTaskDone();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
const normalizeJambones = require('../utils/normalize-jambones');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
|
||||
class Lex extends Task {
|
||||
constructor(logger, opts) {
|
||||
@@ -44,7 +44,7 @@ class Lex extends Task {
|
||||
|
||||
get name() { return TaskName.Lex; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@ const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions, ListenEvents, ListenStatus} = require('../utils/constants');
|
||||
const makeTask = require('./make_task');
|
||||
const moment = require('moment');
|
||||
const MAX_PLAY_AUDIO_QUEUE_SIZE = 10;
|
||||
|
||||
class TaskListen extends Task {
|
||||
constructor(logger, opts, parentTask) {
|
||||
@@ -10,7 +11,7 @@ class TaskListen extends Task {
|
||||
|
||||
[
|
||||
'action', 'auth', 'method', 'url', 'finishOnKey', 'maxLength', 'metadata', 'mixType', 'passDtmf', 'playBeep',
|
||||
'sampleRate', 'timeout', 'transcribe', 'wsAuth'
|
||||
'sampleRate', 'timeout', 'transcribe', 'wsAuth', 'disableBidirectionalAudio'
|
||||
].forEach((k) => this[k] = this.data[k]);
|
||||
|
||||
this.mixType = this.mixType || 'mono';
|
||||
@@ -20,13 +21,15 @@ class TaskListen extends Task {
|
||||
this.nested = parentTask instanceof Task;
|
||||
|
||||
this.results = {};
|
||||
this.playAudioQueue = [];
|
||||
this.isPlayingAudioFromQueue = false;
|
||||
|
||||
if (this.transcribe) this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe}, this);
|
||||
}
|
||||
|
||||
get name() { return TaskName.Listen; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
this.ep = ep;
|
||||
this._dtmfHandler = this._onDtmf.bind(this, ep);
|
||||
@@ -40,7 +43,7 @@ class TaskListen extends Task {
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.transcribeTask.summary}`);
|
||||
this.transcribeTask.span = span;
|
||||
this.transcribeTask.ctx = ctx;
|
||||
this.transcribeTask.exec(cs, ep)
|
||||
this.transcribeTask.exec(cs, {ep})
|
||||
.then((result) => span.end())
|
||||
.catch((err) => span.end());
|
||||
}
|
||||
@@ -58,16 +61,24 @@ class TaskListen extends Task {
|
||||
super.kill(cs);
|
||||
this.logger.debug(`TaskListen:kill endpoint connected? ${this.ep && this.ep.connected}`);
|
||||
this._clearTimer();
|
||||
this.playAudioQueue = [];
|
||||
if (this.ep && this.ep.connected) {
|
||||
this.logger.debug('TaskListen:kill closing websocket');
|
||||
await this.ep.forkAudioStop()
|
||||
.catch((err) => this.logger.info(err, 'TaskListen:kill'));
|
||||
try {
|
||||
await this.ep.forkAudioStop();
|
||||
this.logger.debug('TaskListen:kill successfully closed websocket');
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'TaskListen:kill');
|
||||
}
|
||||
}
|
||||
if (this.recordStartTime) {
|
||||
const duration = moment().diff(this.recordStartTime, 'seconds');
|
||||
this.results.dialCallDuration = duration;
|
||||
}
|
||||
if (this.transcribeTask) await this.transcribeTask.kill(cs);
|
||||
if (this.transcribeTask) {
|
||||
await this.transcribeTask.kill(cs);
|
||||
this.transcribeTask = null;
|
||||
}
|
||||
this.ep && this._removeListeners(this.ep);
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
@@ -129,7 +140,9 @@ class TaskListen extends Task {
|
||||
}
|
||||
|
||||
/* support bi-directional audio */
|
||||
ep.addCustomEventListener(ListenEvents.PlayAudio, this._onPlayAudio.bind(this, ep));
|
||||
if (!this.disableBiDirectionalAudio) {
|
||||
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));
|
||||
}
|
||||
@@ -175,16 +188,36 @@ class TaskListen extends Task {
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
async _onPlayAudio(ep, evt) {
|
||||
this.logger.info(`received play_audio event: ${JSON.stringify(evt)}`);
|
||||
async _playAudio(ep, evt, logger) {
|
||||
try {
|
||||
const results = await ep.play(evt.file);
|
||||
this.logger.debug(`Finished playing file, result: ${JSON.stringify(results)}`);
|
||||
logger.debug(`Finished playing file, result: ${JSON.stringify(results)}`);
|
||||
ep.forkAudioSendText({type: 'playDone', data: Object.assign({id: evt.id}, results)});
|
||||
} catch (err) {
|
||||
logger.error({err}, 'Error playing file');
|
||||
}
|
||||
catch (err) {
|
||||
this.logger.error({err}, 'Error playing file');
|
||||
}
|
||||
|
||||
async _onPlayAudio(ep, evt) {
|
||||
this.logger.info(`received play_audio event: ${JSON.stringify(evt)}`);
|
||||
if (!evt.queuePlay) {
|
||||
this.playAudioQueue = [];
|
||||
this._playAudio(ep, evt, this.logger);
|
||||
this.isPlayingAudioFromQueue = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.playAudioQueue.length <= MAX_PLAY_AUDIO_QUEUE_SIZE) {
|
||||
this.playAudioQueue.push(evt);
|
||||
}
|
||||
|
||||
if (this.isPlayingAudioFromQueue) return;
|
||||
|
||||
this.isPlayingAudioFromQueue = true;
|
||||
while (this.playAudioQueue.length > 0) {
|
||||
await this._playAudio(ep, this.playAudioQueue.shift(), this.logger);
|
||||
}
|
||||
this.isPlayingAudioFromQueue = false;
|
||||
}
|
||||
|
||||
_onKillAudio(ep) {
|
||||
@@ -212,7 +245,7 @@ class TaskListen extends Task {
|
||||
this.logger.debug('Listen:whisper tasks starting');
|
||||
while (tasks.length && !cs.callGone) {
|
||||
const task = tasks.shift();
|
||||
await task.exec(cs, this.ep);
|
||||
await task.exec(cs, {ep: this.ep});
|
||||
}
|
||||
this.logger.debug('Listen:whisper tasks complete');
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const Task = require('./task');
|
||||
const { validateVerb } = require('@jambonz/verb-specifications');
|
||||
const {TaskName} = require('../utils/constants');
|
||||
const errBadInstruction = new Error('malformed jambonz application payload');
|
||||
|
||||
@@ -12,7 +12,7 @@ function makeTask(logger, obj, parent) {
|
||||
if (typeof data !== 'object') {
|
||||
throw errBadInstruction;
|
||||
}
|
||||
Task.validate(name, data);
|
||||
validateVerb(name, data, logger);
|
||||
switch (name) {
|
||||
case TaskName.SipDecline:
|
||||
const TaskSipDecline = require('./sip_decline');
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
const bent = require('bent');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
const uuidv4 = require('uuid-random');
|
||||
const {K8S} = require('../config');
|
||||
class TaskMessage extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
@@ -42,7 +42,7 @@ class TaskMessage extends Task {
|
||||
}
|
||||
if (gw) {
|
||||
this.logger.info({gw, accountSid}, 'Message:exec - using smpp to send message');
|
||||
url = process.env.K8S ? 'http://smpp' : getSmpp();
|
||||
url = K8S ? 'http://smpp' : getSmpp();
|
||||
relativeUrl = '/sms';
|
||||
payload = {
|
||||
...payload,
|
||||
|
||||
@@ -10,7 +10,7 @@ class TaskPause extends Task {
|
||||
|
||||
get name() { return TaskName.Pause; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs) {
|
||||
await super.exec(cs);
|
||||
this.timer = setTimeout(this.notifyTaskDone.bind(this), this.length * 1000);
|
||||
await this.awaitTaskDone();
|
||||
|
||||
@@ -7,6 +7,8 @@ class TaskPlay extends Task {
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
this.url = this.data.url;
|
||||
this.seekOffset = this.data.seekOffset || -1;
|
||||
this.timeoutSecs = this.data.timeoutSecs || -1;
|
||||
this.loop = this.data.loop || 1;
|
||||
this.earlyMedia = this.data.earlyMedia === true;
|
||||
}
|
||||
@@ -17,18 +19,54 @@ class TaskPlay extends Task {
|
||||
return `${this.name}:{url=${this.url}}`;
|
||||
}
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
this.ep = ep;
|
||||
let timeout;
|
||||
let playbackSeconds = 0;
|
||||
let playbackMilliseconds = 0;
|
||||
let completed = !(this.timeoutSecs > 0 || this.loop);
|
||||
if (this.timeoutSecs > 0) {
|
||||
timeout = setTimeout(async() => {
|
||||
completed = true;
|
||||
try {
|
||||
await this.kill(cs);
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'Error killing audio on timeoutSecs');
|
||||
}
|
||||
}, this.timeoutSecs * 1000);
|
||||
}
|
||||
try {
|
||||
this.notifyStatus({event: 'start-playback'});
|
||||
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);
|
||||
if (Array.isArray(this.url)) {
|
||||
for (const playUrl of this.url) {
|
||||
await this.playToConfMember(this.ep, memberId, confName, confUuid, playUrl);
|
||||
}
|
||||
} else {
|
||||
await this.playToConfMember(this.ep, memberId, confName, confUuid, this.url);
|
||||
}
|
||||
} else {
|
||||
let file = this.url;
|
||||
if (this.seekOffset >= 0) {
|
||||
file = {file: this.url, seekOffset: this.seekOffset};
|
||||
this.seekOffset = -1;
|
||||
}
|
||||
const result = await ep.play(file);
|
||||
playbackSeconds += parseInt(result.playbackSeconds);
|
||||
playbackMilliseconds += parseInt(result.playbackMilliseconds);
|
||||
if (this.killed || !this.loop || completed) {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
await this.performAction(
|
||||
Object.assign(result, {reason: 'playCompleted', playbackSeconds, playbackMilliseconds}),
|
||||
!(this.parentTask || cs.isConfirmCallSession));
|
||||
}
|
||||
}
|
||||
else await ep.play(this.url);
|
||||
}
|
||||
} catch (err) {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
this.logger.info(err, `TaskPlay:exec - error playing ${this.url}`);
|
||||
}
|
||||
this.emit('playDone');
|
||||
@@ -43,7 +81,8 @@ class TaskPlay extends Task {
|
||||
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'));
|
||||
this.notifyStatus({event: 'kill-playback'});
|
||||
this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class Rasa extends Task {
|
||||
return this.reportedFinalAction || this.isReplacingApplication;
|
||||
}
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
|
||||
this.ep = ep;
|
||||
@@ -34,7 +34,7 @@ class Rasa extends Task {
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.gatherTask.summary}`);
|
||||
this.gatherTask.span = span;
|
||||
this.gatherTask.ctx = ctx;
|
||||
this.gatherTask.exec(cs, ep, this)
|
||||
this.gatherTask.exec(cs, {ep})
|
||||
.then(() => span.end())
|
||||
.catch((err) => {
|
||||
span.end();
|
||||
@@ -128,7 +128,7 @@ class Rasa extends Task {
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.gatherTask.summary}`);
|
||||
this.gatherTask.span = span;
|
||||
this.gatherTask.ctx = ctx;
|
||||
this.gatherTask.exec(cs, ep, this)
|
||||
this.gatherTask.exec(cs, {ep})
|
||||
.then(() => span.end())
|
||||
.catch((err) => {
|
||||
span.end();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName} = require('../utils/constants');
|
||||
const makeTask = require('./make_task');
|
||||
const normalizeJambones = require('../utils/normalize-jambones');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
|
||||
/**
|
||||
* Manages an outdial made via REST API
|
||||
@@ -11,6 +11,8 @@ class TaskRestDial extends Task {
|
||||
super(logger, opts);
|
||||
|
||||
this.from = this.data.from;
|
||||
this.callerName = this.data.callerName;
|
||||
this.fromHost = this.data.fromHost;
|
||||
this.to = this.data.to;
|
||||
this.call_hook = this.data.call_hook;
|
||||
this.timeout = this.data.timeout || 60;
|
||||
@@ -26,24 +28,35 @@ class TaskRestDial extends Task {
|
||||
*/
|
||||
async exec(cs) {
|
||||
await super.exec(cs);
|
||||
this.req = cs.req;
|
||||
this.cs = cs;
|
||||
this.canCancel = true;
|
||||
|
||||
if (this.data.amd) {
|
||||
this.startAmd = cs.startAmd;
|
||||
this.stopAmd = cs.stopAmd;
|
||||
this.on('amd', this._onAmdEvent.bind(this, cs));
|
||||
}
|
||||
|
||||
this._setCallTimer();
|
||||
await this.awaitTaskDone();
|
||||
}
|
||||
|
||||
turnOffAmd() {
|
||||
if (this.callSession.ep && this.callSession.ep.amd) this.stopAmd(this.callSession.ep, this);
|
||||
}
|
||||
|
||||
kill(cs) {
|
||||
super.kill(cs);
|
||||
this._clearCallTimer();
|
||||
if (this.req) {
|
||||
this.req.cancel();
|
||||
this.req = null;
|
||||
if (this.canCancel) {
|
||||
this.canCancel = false;
|
||||
cs?.req?.cancel();
|
||||
}
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
async _onConnect(dlg) {
|
||||
this.req = null;
|
||||
this.canCancel = false;
|
||||
const cs = this.callSession;
|
||||
cs.setDialog(dlg);
|
||||
|
||||
@@ -64,6 +77,13 @@ class TaskRestDial extends Task {
|
||||
}
|
||||
}
|
||||
};
|
||||
if (this.startAmd) {
|
||||
try {
|
||||
this.startAmd(this.callSession, this.callSession.ep, this, this.data.amd);
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Rest:dial:Call established - Error calling startAmd');
|
||||
}
|
||||
}
|
||||
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`);
|
||||
@@ -78,7 +98,7 @@ class TaskRestDial extends Task {
|
||||
_onCallStatus(status) {
|
||||
this.logger.debug(`CallStatus: ${status}`);
|
||||
if (status >= 200) {
|
||||
this.req = null;
|
||||
this.canCancel = false;
|
||||
this._clearCallTimer();
|
||||
if (status !== 200) this.notifyTaskDone();
|
||||
}
|
||||
@@ -96,7 +116,16 @@ class TaskRestDial extends Task {
|
||||
_onCallTimeout() {
|
||||
this.logger.debug('TaskRestDial: timeout expired without answer, killing task');
|
||||
this.timer = null;
|
||||
this.kill();
|
||||
this.kill(this.cs);
|
||||
}
|
||||
|
||||
_onAmdEvent(cs, evt) {
|
||||
this.logger.info({evt}, 'Rest:dial:_onAmdEvent');
|
||||
const {actionHook} = this.data.amd;
|
||||
this.performHook(cs, actionHook, evt)
|
||||
.catch((err) => {
|
||||
this.logger.error({err}, 'Rest:dial:_onAmdEvent - error calling actionHook');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ class TaskSayLegacy extends Task {
|
||||
|
||||
get name() { return TaskName.SayLegacy; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
super.exec(cs);
|
||||
this.ep = ep;
|
||||
try {
|
||||
|
||||
@@ -1,15 +1,42 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
const pollySSMLSplit = require('polly-ssml-split');
|
||||
|
||||
const breakLengthyTextIfNeeded = (logger, text) => {
|
||||
const chunkSize = 1000;
|
||||
const isSSML = text.startsWith('<speak>');
|
||||
if (text.length <= chunkSize || !isSSML) return [text];
|
||||
const options = {
|
||||
// MIN length
|
||||
softLimit: 100,
|
||||
// MAX length, exclude 15 characters <speak></speak>
|
||||
hardLimit: chunkSize - 15,
|
||||
// Set of extra split characters (Optional property)
|
||||
extraSplitChars: ',;!?',
|
||||
};
|
||||
pollySSMLSplit.configure(options);
|
||||
try {
|
||||
return pollySSMLSplit.split(text);
|
||||
} catch (err) {
|
||||
logger.info({err}, 'Error spliting SSML long text');
|
||||
return [text];
|
||||
}
|
||||
};
|
||||
|
||||
class TaskSay extends Task {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
this.text = Array.isArray(this.data.text) ? this.data.text : [this.data.text];
|
||||
this.text = (Array.isArray(this.data.text) ? this.data.text : [this.data.text])
|
||||
.map((t) => breakLengthyTextIfNeeded(this.logger, t))
|
||||
.flat();
|
||||
|
||||
this.loop = this.data.loop || 1;
|
||||
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
|
||||
this.synthesizer = this.data.synthesizer || {};
|
||||
this.disableTtsCache = this.data.disableTtsCache;
|
||||
this.options = this.synthesizer.options || {};
|
||||
}
|
||||
|
||||
get name() { return TaskName.Say; }
|
||||
@@ -22,7 +49,7 @@ class TaskSay extends Task {
|
||||
return `${this.name}{${this.text[0]}}`;
|
||||
}
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
|
||||
const {srf} = cs;
|
||||
@@ -35,14 +62,34 @@ class TaskSay extends Task {
|
||||
const language = this.synthesizer.language && this.synthesizer.language !== 'default' ?
|
||||
this.synthesizer.language :
|
||||
cs.speechSynthesisLanguage ;
|
||||
const voice = this.synthesizer.voice && this.synthesizer.voice !== 'default' ?
|
||||
let 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');
|
||||
let credentials = cs.getSpeechCredentials(vendor, 'tts');
|
||||
|
||||
this.logger.info({vendor, language, voice}, 'TaskSay:exec');
|
||||
/* parse Nuance voices into name and model */
|
||||
let model;
|
||||
if (vendor === 'nuance' && voice) {
|
||||
const arr = /([A-Za-z-]*)\s+-\s+(enhanced|standard)/.exec(voice);
|
||||
if (arr) {
|
||||
voice = arr[1];
|
||||
model = arr[2];
|
||||
}
|
||||
}
|
||||
|
||||
/* allow for microsoft custom region voice and api_key to be specified as an override */
|
||||
if (vendor === 'microsoft' && this.options.deploymentId) {
|
||||
credentials = credentials || {};
|
||||
credentials.use_custom_tts = true;
|
||||
credentials.custom_tts_endpoint = this.options.deploymentId;
|
||||
credentials.api_key = this.options.apiKey || credentials.apiKey;
|
||||
credentials.region = this.options.region || credentials.region;
|
||||
voice = this.options.voice || voice;
|
||||
}
|
||||
|
||||
this.logger.info({vendor, language, voice, model}, 'TaskSay:exec');
|
||||
this.ep = ep;
|
||||
try {
|
||||
if (!credentials) {
|
||||
@@ -51,7 +98,10 @@ 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}`);
|
||||
this.notifyError({
|
||||
msg: 'TTS error',
|
||||
details:`No speech credentials provisioned for selected vendor ${vendor}`
|
||||
});
|
||||
throw new Error('no provisioned speech credentials for TTS');
|
||||
}
|
||||
// synthesize all of the text elements
|
||||
@@ -69,14 +119,16 @@ class TaskSay extends Task {
|
||||
'tts.voice': voice
|
||||
});
|
||||
try {
|
||||
const {filePath, servedFromCache} = await synthAudio(stats, {
|
||||
const {filePath, servedFromCache, rtt} = await synthAudio(stats, {
|
||||
text,
|
||||
vendor,
|
||||
language,
|
||||
voice,
|
||||
engine,
|
||||
model,
|
||||
salt,
|
||||
credentials
|
||||
credentials,
|
||||
disableTtsCache : this.disableTtsCache
|
||||
});
|
||||
this.logger.debug(`file ${filePath}, served from cache ${servedFromCache}`);
|
||||
if (filePath) cs.trackTmpFile(filePath);
|
||||
@@ -87,24 +139,33 @@ class TaskSay extends Task {
|
||||
}
|
||||
span.setAttributes({'tts.cached': servedFromCache});
|
||||
span.end();
|
||||
if (!servedFromCache && rtt) {
|
||||
this.notifyStatus({
|
||||
event: 'synthesized-audio',
|
||||
vendor,
|
||||
language,
|
||||
characters: text.length,
|
||||
elapsedTime: rtt
|
||||
});
|
||||
}
|
||||
return filePath;
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Error synthesizing tts');
|
||||
span.end();
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.TTS_NOT_PROVISIONED,
|
||||
alert_type: AlertType.TTS_FAILURE,
|
||||
vendor,
|
||||
detail: err.message
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for tts failure'));
|
||||
this.notifyError(err.message || err);
|
||||
this.notifyError({msg: 'TTS error', details: err.message || err});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const arr = this.text.map((t) => generateAudio(t));
|
||||
const filepath = (await Promise.all(arr)).filter((fp) => fp && fp.length);
|
||||
this.logger.debug({filepath}, 'synthesized files for tts');
|
||||
this.notifyStatus({event: 'start-playback'});
|
||||
|
||||
while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep?.connected) {
|
||||
let segment = 0;
|
||||
@@ -136,6 +197,7 @@ class TaskSay extends Task {
|
||||
this.killPlayToConfMember(this.ep, memberId, confName);
|
||||
}
|
||||
else {
|
||||
this.notifyStatus({event: 'kill-playback'});
|
||||
this.ep.api('uuid_break', this.ep.uuid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ class TaskSipRefer extends Task {
|
||||
method: 'REFER',
|
||||
headers: {
|
||||
...this.headers,
|
||||
...(this.referToIsUri && {'X-Refer-To-Leave-Untouched': true}),
|
||||
'Refer-To': referTo,
|
||||
'Referred-By': referredBy
|
||||
}
|
||||
@@ -46,7 +47,17 @@ class TaskSipRefer extends Task {
|
||||
|
||||
/* if we fail, fall through to next verb. If success, we should get BYE from far end */
|
||||
if (this.referStatus === 202) {
|
||||
this._notifyTimer = setTimeout(() => {
|
||||
this.logger.info('TaskSipRefer:exec - no NOTIFY received in 15 secs, exiting');
|
||||
this.performAction({refer_status: this.referStatus})
|
||||
.catch((err) => this.logger.error(err, 'TaskSipRefer:exec - error performing action'));
|
||||
this.notifyTaskDone();
|
||||
}, 15000);
|
||||
await this.awaitTaskDone();
|
||||
if (this._notifyTimer) {
|
||||
clearTimeout(this._notifyTimer);
|
||||
this._notifyTimer = null;
|
||||
}
|
||||
}
|
||||
else {
|
||||
await this.performAction({refer_status: this.referStatus});
|
||||
@@ -70,10 +81,10 @@ class TaskSipRefer extends Task {
|
||||
const contentType = req.get('Content-Type');
|
||||
this.logger.debug({body: req.body}, `TaskSipRefer:_handleNotify got ${contentType}`);
|
||||
|
||||
if (contentType === 'message/sipfrag') {
|
||||
if (contentType?.includes('message/sipfrag')) {
|
||||
const arr = /SIP\/2\.0\s+(\d+)/.exec(req.body);
|
||||
if (arr) {
|
||||
const status = arr[1];
|
||||
const status = typeof arr[1] === 'string' ? parseInt(arr[1], 10) : arr[1];
|
||||
this.logger.debug(`TaskSipRefer:_handleNotify: call got status ${status}`);
|
||||
if (this.eventHook) {
|
||||
const b3 = this.getTracingPropagation();
|
||||
@@ -100,6 +111,7 @@ class TaskSipRefer extends Task {
|
||||
/* they may have only provided a phone number/user */
|
||||
referTo = `sip:${referTo}@${host}`;
|
||||
}
|
||||
else this.referToIsUri = true;
|
||||
if (!referredBy) {
|
||||
/* default */
|
||||
referredBy = cs.req?.callingNumber || dlg.local.uri;
|
||||
|
||||
@@ -17,7 +17,7 @@ class TaskSipRequest extends Task {
|
||||
|
||||
get name() { return TaskName.SipRequest; }
|
||||
|
||||
async exec(cs, dlg) {
|
||||
async exec(cs, {dlg}) {
|
||||
super.exec(cs);
|
||||
try {
|
||||
this.logger.info({dlg}, `TaskSipRequest: sending a SIP ${this.method}`);
|
||||
|
||||
@@ -1,521 +0,0 @@
|
||||
{
|
||||
"sip:decline": {
|
||||
"properties": {
|
||||
"status": "number",
|
||||
"reason": "string",
|
||||
"headers": "object"
|
||||
},
|
||||
"required": [
|
||||
"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",
|
||||
"actionHook": "object|string",
|
||||
"timeout": "number",
|
||||
"beep": "boolean"
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"enqueue": {
|
||||
"properties": {
|
||||
"name": "string",
|
||||
"actionHook": "object|string",
|
||||
"waitHook": "object|string",
|
||||
"_": "object"
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"leave": {
|
||||
"properties": {
|
||||
|
||||
}
|
||||
},
|
||||
"hangup": {
|
||||
"properties": {
|
||||
"headers": "object"
|
||||
},
|
||||
"required": [
|
||||
]
|
||||
},
|
||||
"play": {
|
||||
"properties": {
|
||||
"url": "string",
|
||||
"loop": "number|string",
|
||||
"earlyMedia": "boolean"
|
||||
},
|
||||
"required": [
|
||||
"url"
|
||||
]
|
||||
},
|
||||
"say": {
|
||||
"properties": {
|
||||
"text": "string|array",
|
||||
"loop": "number|string",
|
||||
"synthesizer": "#synthesizer",
|
||||
"earlyMedia": "boolean"
|
||||
},
|
||||
"required": [
|
||||
"text"
|
||||
]
|
||||
},
|
||||
"gather": {
|
||||
"properties": {
|
||||
"actionHook": "object|string",
|
||||
"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": [
|
||||
]
|
||||
},
|
||||
"conference": {
|
||||
"properties": {
|
||||
"name": "string",
|
||||
"beep": "boolean",
|
||||
"startConferenceOnEnter": "boolean",
|
||||
"endConferenceOnExit": "boolean",
|
||||
"maxParticipants": "number",
|
||||
"joinMuted": "boolean",
|
||||
"actionHook": "object|string",
|
||||
"waitHook": "object|string",
|
||||
"statusEvents": "array",
|
||||
"statusHook": "object|string",
|
||||
"enterHook": "object|string",
|
||||
"record": "#record"
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"dial": {
|
||||
"properties": {
|
||||
"actionHook": "object|string",
|
||||
"answerOnBridge": "boolean",
|
||||
"callerId": "string",
|
||||
"confirmHook": "object|string",
|
||||
"referHook": "object|string",
|
||||
"dialMusic": "string",
|
||||
"dtmfCapture": "object",
|
||||
"dtmfHook": "object|string",
|
||||
"headers": "object",
|
||||
"listen": "#listen",
|
||||
"target": ["#target"],
|
||||
"timeLimit": "number",
|
||||
"timeout": "number",
|
||||
"proxy": "string",
|
||||
"transcribe": "#transcribe"
|
||||
},
|
||||
"required": [
|
||||
"target"
|
||||
]
|
||||
},
|
||||
"dialogflow": {
|
||||
"properties": {
|
||||
"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",
|
||||
"events": "[string]",
|
||||
"welcomeEvent": "string",
|
||||
"welcomeEventParams": "object",
|
||||
"noInputTimeout": "number",
|
||||
"noInputEvent": "string",
|
||||
"passDtmfAsTextInput": "boolean",
|
||||
"thinkingMusic": "string",
|
||||
"tts": "#synthesizer",
|
||||
"bargein": "boolean"
|
||||
},
|
||||
"required": [
|
||||
"project",
|
||||
"credentials",
|
||||
"lang"
|
||||
]
|
||||
},
|
||||
"dtmf": {
|
||||
"properties": {
|
||||
"dtmf": "string",
|
||||
"duration": "number"
|
||||
},
|
||||
"required": [
|
||||
"dtmf"
|
||||
]
|
||||
},
|
||||
"lex": {
|
||||
"properties": {
|
||||
"botId": "string",
|
||||
"botAlias": "string",
|
||||
"credentials": "object",
|
||||
"region": "string",
|
||||
"locale": "string",
|
||||
"intent": "#lexIntent",
|
||||
"welcomeMessage": "string",
|
||||
"metadata": "object",
|
||||
"bargein": "boolean",
|
||||
"passDtmf": "boolean",
|
||||
"actionHook": "object|string",
|
||||
"eventHook": "object|string",
|
||||
"noInputTimeout": "number",
|
||||
"tts": "#synthesizer"
|
||||
},
|
||||
"required": [
|
||||
"botId",
|
||||
"botAlias",
|
||||
"region",
|
||||
"credentials"
|
||||
]
|
||||
},
|
||||
"listen": {
|
||||
"properties": {
|
||||
"actionHook": "object|string",
|
||||
"auth": "#auth",
|
||||
"finishOnKey": "string",
|
||||
"maxLength": "number",
|
||||
"metadata": "object",
|
||||
"mixType": {
|
||||
"type": "string",
|
||||
"enum": ["mono", "stereo", "mixed"]
|
||||
},
|
||||
"passDtmf": "boolean",
|
||||
"playBeep": "boolean",
|
||||
"sampleRate": "number",
|
||||
"timeout": "number",
|
||||
"transcribe": "#transcribe",
|
||||
"url": "string",
|
||||
"wsAuth": "#auth",
|
||||
"earlyMedia": "boolean"
|
||||
},
|
||||
"required": [
|
||||
"url"
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"properties": {
|
||||
"carrier": "string",
|
||||
"account_sid": "string",
|
||||
"message_sid": "string",
|
||||
"to": "string",
|
||||
"from": "string",
|
||||
"text": "string",
|
||||
"media": "string|array",
|
||||
"actionHook": "object|string"
|
||||
},
|
||||
"required": [
|
||||
"to",
|
||||
"from"
|
||||
]
|
||||
},
|
||||
"pause": {
|
||||
"properties": {
|
||||
"length": "number"
|
||||
},
|
||||
"required": [
|
||||
"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"
|
||||
},
|
||||
"required": [
|
||||
"actionHook"
|
||||
]
|
||||
},
|
||||
"rest:dial": {
|
||||
"properties": {
|
||||
"account_sid": "string",
|
||||
"application_sid": "string",
|
||||
"call_hook": "object|string",
|
||||
"call_status_hook": "object|string",
|
||||
"from": "string",
|
||||
"speech_synthesis_vendor": "string",
|
||||
"speech_synthesis_voice": "string",
|
||||
"speech_synthesis_language": "string",
|
||||
"speech_recognizer_vendor": "string",
|
||||
"speech_recognizer_language": "string",
|
||||
"tag": "object",
|
||||
"to": "#target",
|
||||
"headers": "object",
|
||||
"timeout": "number"
|
||||
},
|
||||
"required": [
|
||||
"call_hook",
|
||||
"from",
|
||||
"to"
|
||||
]
|
||||
},
|
||||
"tag": {
|
||||
"properties": {
|
||||
"data": "object"
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"transcribe": {
|
||||
"properties": {
|
||||
"transcriptionHook": "string",
|
||||
"recognizer": "#recognizer",
|
||||
"earlyMedia": "boolean"
|
||||
},
|
||||
"required": [
|
||||
"recognizer"
|
||||
]
|
||||
},
|
||||
"target": {
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["phone", "sip", "user", "teams"]
|
||||
},
|
||||
"confirmHook": "object|string",
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["GET", "POST"]
|
||||
},
|
||||
"headers": "object",
|
||||
"name": "string",
|
||||
"number": "string",
|
||||
"sipUri": "string",
|
||||
"auth": "#auth",
|
||||
"vmail": "boolean",
|
||||
"tenant": "string",
|
||||
"trunk": "string",
|
||||
"overrideTo": "string"
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"auth": {
|
||||
"properties": {
|
||||
"username": "string",
|
||||
"password": "string"
|
||||
},
|
||||
"required": [
|
||||
"username",
|
||||
"password"
|
||||
]
|
||||
},
|
||||
"synthesizer": {
|
||||
"properties": {
|
||||
"vendor": {
|
||||
"type": "string",
|
||||
"enum": ["google", "aws", "polly", "microsoft", "default"]
|
||||
},
|
||||
"language": "string",
|
||||
"voice": "string",
|
||||
"engine": {
|
||||
"type": "string",
|
||||
"enum": ["standard", "neural"]
|
||||
},
|
||||
"gender": {
|
||||
"type": "string",
|
||||
"enum": ["MALE", "FEMALE", "NEUTRAL"]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"vendor"
|
||||
]
|
||||
},
|
||||
"recognizer": {
|
||||
"properties": {
|
||||
"vendor": {
|
||||
"type": "string",
|
||||
"enum": ["google", "aws", "microsoft", "default"]
|
||||
},
|
||||
"language": "string",
|
||||
"vad": "#vad",
|
||||
"hints": "array",
|
||||
"hintsBoost": "number",
|
||||
"altLanguages": "array",
|
||||
"profanityFilter": "boolean",
|
||||
"interim": "boolean",
|
||||
"singleUtterance": "boolean",
|
||||
"dualChannel": "boolean",
|
||||
"separateRecognitionPerChannel": "boolean",
|
||||
"punctuation": "boolean",
|
||||
"enhancedModel": "boolean",
|
||||
"words": "boolean",
|
||||
"diarization": "boolean",
|
||||
"diarizationMinSpeakers": "number",
|
||||
"diarizationMaxSpeakers": "number",
|
||||
"interactionType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"unspecified",
|
||||
"discussion",
|
||||
"presentation",
|
||||
"phone_call",
|
||||
"voicemail",
|
||||
"voice_search",
|
||||
"voice_command",
|
||||
"dictation"
|
||||
]
|
||||
},
|
||||
"naicsCode": "number",
|
||||
"identifyChannels": "boolean",
|
||||
"vocabularyName": "string",
|
||||
"vocabularyFilterName": "string",
|
||||
"filterMethod": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"remove",
|
||||
"mask",
|
||||
"tag"
|
||||
]
|
||||
},
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"lexIntent": {
|
||||
"properties": {
|
||||
"name": "string",
|
||||
"slots": "object"
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"vad": {
|
||||
"properties": {
|
||||
"enable": "boolean",
|
||||
"voiceMs": "number",
|
||||
"mode": "number"
|
||||
},
|
||||
"required": [
|
||||
"enable"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
const Emitter = require('events');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const debug = require('debug')('jambonz:feature-server');
|
||||
const assert = require('assert');
|
||||
const uuidv4 = require('uuid-random');
|
||||
const {TaskPreconditions} = require('../utils/constants');
|
||||
const normalizeJambones = require('../utils/normalize-jambones');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const WsRequestor = require('../utils/ws-requestor');
|
||||
const {TaskName} = require('../utils/constants');
|
||||
const {trace} = require('@opentelemetry/api');
|
||||
const specs = new Map();
|
||||
const _specData = require('./specs');
|
||||
for (const key in _specData) {specs.set(key, _specData[key]);}
|
||||
|
||||
/**
|
||||
* @classdesc Represents a jambonz verb. This is a superclass that is extended
|
||||
@@ -21,6 +18,7 @@ class Task extends Emitter {
|
||||
this.logger = logger;
|
||||
this.data = data;
|
||||
this.actionHook = this.data.actionHook;
|
||||
this.id = data.id;
|
||||
|
||||
this._killInProgress = false;
|
||||
this._completionPromise = new Promise((resolve) => this._completionResolver = resolve);
|
||||
@@ -137,21 +135,32 @@ 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'));
|
||||
notifyError(obj) {
|
||||
if (this.cs.requestor instanceof WsRequestor) {
|
||||
const params = {...obj, verb: this.name, id: this.id};
|
||||
this.cs.requestor.request('jambonz:error', '/error', params)
|
||||
.catch((err) => this.logger.info({err}, 'Task:notifyError error sending error'));
|
||||
}
|
||||
}
|
||||
|
||||
notifyStatus(obj) {
|
||||
if (this.cs.notifyEvents && this.cs.requestor instanceof WsRequestor) {
|
||||
const params = {...obj, verb: this.name, id: this.id};
|
||||
this.cs.requestor.request('verb:status', '/status', params)
|
||||
.catch((err) => this.logger.info({err}, 'Task:notifyStatus error sending error'));
|
||||
}
|
||||
}
|
||||
|
||||
async performAction(results, expectResponse = true) {
|
||||
if (this.actionHook) {
|
||||
const type = this.name === TaskName.Redirect ? 'session:redirect' : 'verb:hook';
|
||||
const params = results ? Object.assign(this.cs.callInfo.toJSON(), results) : this.cs.callInfo.toJSON();
|
||||
const span = this.startSpan('verb:hook', {'hook.url': this.actionHook});
|
||||
const span = this.startSpan(`${type} (${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);
|
||||
const json = await this.cs.requestor.request(type, this.actionHook, params, httpHeaders);
|
||||
span.setAttributes({'http.statusCode': 200});
|
||||
span.end();
|
||||
if (expectResponse && json && Array.isArray(json)) {
|
||||
@@ -171,12 +180,13 @@ class Task extends Emitter {
|
||||
}
|
||||
|
||||
async performHook(cs, hook, results) {
|
||||
const params = results ? Object.assign(cs.callInfo.toJSON(), results) : cs.callInfo.toJSON();
|
||||
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)});
|
||||
span.setAttributes({'http.body': JSON.stringify(params)});
|
||||
try {
|
||||
const json = await cs.requestor.request('verb:hook', hook, results, httpHeaders);
|
||||
const json = await cs.requestor.request('verb:hook', hook, params, httpHeaders);
|
||||
span.setAttributes({'http.statusCode': 200});
|
||||
span.end();
|
||||
if (json && Array.isArray(json)) {
|
||||
@@ -272,74 +282,6 @@ class Task extends Emitter {
|
||||
this.logger.error(err, 'Task:_doRefer error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* validate that the JSON task description is valid
|
||||
* @param {string} name - verb name
|
||||
* @param {object} data - verb properties
|
||||
*/
|
||||
static validate(name, data) {
|
||||
debug(`validating ${name} with data ${JSON.stringify(data)}`);
|
||||
// validate the instruction is supported
|
||||
if (!specs.has(name)) throw new Error(`invalid instruction: ${name}`);
|
||||
|
||||
// check type of each element and make sure required elements are present
|
||||
const specData = specs.get(name);
|
||||
let required = specData.required || [];
|
||||
for (const dKey in data) {
|
||||
if (dKey in specData.properties) {
|
||||
const dVal = data[dKey];
|
||||
const dSpec = specData.properties[dKey];
|
||||
debug(`Task:validate validating property ${dKey} with value ${JSON.stringify(dVal)}`);
|
||||
|
||||
if (typeof dSpec === 'string' && dSpec === 'array') {
|
||||
if (!Array.isArray(dVal)) throw new Error(`${name}: property ${dKey} is not an array`);
|
||||
}
|
||||
else if (typeof dSpec === 'string' && dSpec.includes('|')) {
|
||||
const types = dSpec.split('|').map((t) => t.trim());
|
||||
if (!types.includes(typeof dVal) && !(types.includes('array') && Array.isArray(dVal))) {
|
||||
throw new Error(`${name}: property ${dKey} has invalid data type, must be one of ${types}`);
|
||||
}
|
||||
}
|
||||
else if (typeof dSpec === 'string' && ['number', 'string', 'object', 'boolean'].includes(dSpec)) {
|
||||
// simple types
|
||||
if (typeof dVal !== specData.properties[dKey]) {
|
||||
throw new Error(`${name}: property ${dKey} has invalid data type`);
|
||||
}
|
||||
}
|
||||
else if (Array.isArray(dSpec) && dSpec[0].startsWith('#')) {
|
||||
const name = dSpec[0].slice(1);
|
||||
for (const item of dVal) {
|
||||
Task.validate(name, item);
|
||||
}
|
||||
}
|
||||
else if (typeof dSpec === 'object') {
|
||||
// complex types
|
||||
const type = dSpec.type;
|
||||
assert.ok(['number', 'string', 'object', 'boolean'].includes(type),
|
||||
`invalid or missing type in spec ${JSON.stringify(dSpec)}`);
|
||||
if (type === 'string' && dSpec.enum) {
|
||||
assert.ok(Array.isArray(dSpec.enum), `enum must be an array ${JSON.stringify(dSpec.enum)}`);
|
||||
if (!dSpec.enum.includes(dVal)) throw new Error(`invalid value ${dVal} must be one of ${dSpec.enum}`);
|
||||
}
|
||||
}
|
||||
else if (typeof dSpec === 'string' && dSpec.startsWith('#')) {
|
||||
// reference to another datatype (i.e. nested type)
|
||||
const name = dSpec.slice(1);
|
||||
//const obj = {};
|
||||
//obj[name] = dVal;
|
||||
Task.validate(name, dVal);
|
||||
}
|
||||
else {
|
||||
assert.ok(0, `invalid spec ${JSON.stringify(dSpec)}`);
|
||||
}
|
||||
required = required.filter((item) => item !== dKey);
|
||||
}
|
||||
else throw new Error(`${name}: unknown property ${dKey}`);
|
||||
}
|
||||
if (required.length > 0) throw new Error(`${name}: missing value for ${required}`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Task;
|
||||
|
||||
@@ -3,9 +3,16 @@ const {
|
||||
TaskName,
|
||||
TaskPreconditions,
|
||||
GoogleTranscriptionEvents,
|
||||
NuanceTranscriptionEvents,
|
||||
AwsTranscriptionEvents,
|
||||
AzureTranscriptionEvents,
|
||||
AwsTranscriptionEvents
|
||||
DeepgramTranscriptionEvents,
|
||||
SonioxTranscriptionEvents,
|
||||
IbmTranscriptionEvents,
|
||||
NvidiaTranscriptionEvents,
|
||||
JambonzTranscriptionEvents
|
||||
} = require('../utils/constants');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
|
||||
class TaskTranscribe extends Task {
|
||||
constructor(logger, opts, parentTask) {
|
||||
@@ -13,6 +20,18 @@ class TaskTranscribe extends Task {
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
this.parentTask = parentTask;
|
||||
|
||||
const {
|
||||
setChannelVarsForStt,
|
||||
normalizeTranscription,
|
||||
removeSpeechListeners,
|
||||
setSpeechCredentialsAtRuntime,
|
||||
compileSonioxTranscripts
|
||||
} = require('../utils/transcription-utils')(logger);
|
||||
this.setChannelVarsForStt = setChannelVarsForStt;
|
||||
this.normalizeTranscription = normalizeTranscription;
|
||||
this.removeSpeechListeners = removeSpeechListeners;
|
||||
this.compileSonioxTranscripts = compileSonioxTranscripts;
|
||||
|
||||
this.transcriptionHook = this.data.transcriptionHook;
|
||||
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
|
||||
|
||||
@@ -22,51 +41,53 @@ 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};
|
||||
/* let credentials be supplied in the recognizer object at runtime */
|
||||
this.sttCredentials = setSpeechCredentialsAtRuntime(recognizer);
|
||||
|
||||
/* 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;
|
||||
this.interactionType = recognizer.interactionType || 'unspecified';
|
||||
this.naicsCode = recognizer.naicsCode || 0;
|
||||
this.altLanguages = recognizer.altLanguages || [];
|
||||
/* buffer for soniox transcripts */
|
||||
this._sonioxTranscripts = [];
|
||||
|
||||
/* aws-specific options */
|
||||
this.identifyChannels = !!recognizer.identifyChannels;
|
||||
this.vocabularyName = recognizer.vocabularyName;
|
||||
this.vocabularyFilterName = recognizer.vocabularyFilterName;
|
||||
this.filterMethod = recognizer.filterMethod;
|
||||
|
||||
/* microsoft options */
|
||||
this.outputFormat = recognizer.outputFormat || 'simple';
|
||||
this.profanityOption = recognizer.profanityOption || 'raw';
|
||||
this.requestSnr = recognizer.requestSnr || false;
|
||||
this.initialSpeechTimeoutMs = recognizer.initialSpeechTimeoutMs || 0;
|
||||
this.azureServiceEndpoint = recognizer.azureServiceEndpoint;
|
||||
recognizer.hints = recognizer.hints || [];
|
||||
recognizer.altLanguages = recognizer.altLanguages || [];
|
||||
}
|
||||
|
||||
get name() { return TaskName.Transcribe; }
|
||||
|
||||
async exec(cs, ep, ep2) {
|
||||
async exec(cs, {ep, ep2}) {
|
||||
super.exec(cs);
|
||||
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
|
||||
const {getNuanceAccessToken, getIbmAccessToken} = cs.srf.locals.dbHelpers;
|
||||
|
||||
if (cs.hasGlobalSttHints) {
|
||||
const {hints, hintsBoost} = cs.globalSttHints;
|
||||
this.data.recognizer.hints = this.data.recognizer.hints.concat(hints);
|
||||
if (!this.data.recognizer.hintsBoost && hintsBoost) this.data.recognizer.hintsBoost = hintsBoost;
|
||||
this.logger.debug({hints: this.data.recognizer.hints, hintsBoost: this.data.recognizer.hintsBoost},
|
||||
'Transcribe:exec - applying global sttHints');
|
||||
}
|
||||
if (cs.hasAltLanguages) {
|
||||
this.data.recognizer.altLanguages = this.data.recognizer.altLanguages.concat(cs.altLanguages);
|
||||
this.logger.debug({altLanguages: this.altLanguages},
|
||||
'Transcribe:exec - applying altLanguages');
|
||||
}
|
||||
if (cs.hasGlobalSttPunctuation && !this.data.recognizer.punctuation) {
|
||||
this.data.recognizer.punctuation = cs.globalSttPunctuation;
|
||||
}
|
||||
|
||||
this.ep = ep;
|
||||
this.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');
|
||||
if ('default' === this.vendor || !this.vendor) {
|
||||
this.vendor = cs.speechRecognizerVendor;
|
||||
if (this.data.recognizer) this.data.recognizer.vendor = this.vendor;
|
||||
}
|
||||
if ('default' === this.language || !this.language) {
|
||||
this.language = cs.speechRecognizerLanguage;
|
||||
if (this.data.recognizer) this.data.recognizer.language = this.language;
|
||||
}
|
||||
if (!this.data.recognizer.vendor) {
|
||||
this.data.recognizer.vendor = this.vendor;
|
||||
}
|
||||
if (!this.sttCredentials) this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt');
|
||||
|
||||
try {
|
||||
if (!this.sttCredentials) {
|
||||
@@ -79,8 +100,26 @@ class TaskTranscribe extends Task {
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for no stt'));
|
||||
throw new Error('no provisioned speech credentials for TTS');
|
||||
}
|
||||
|
||||
if (this.vendor === 'nuance' && this.sttCredentials.client_id) {
|
||||
/* get nuance access token */
|
||||
const {client_id, secret} = this.sttCredentials;
|
||||
const {access_token, servedFromCache} = await getNuanceAccessToken(client_id, secret, 'asr tts');
|
||||
this.logger.debug({client_id},
|
||||
`Transcribe:exec - got nuance access token ${servedFromCache ? 'from cache' : ''}`);
|
||||
this.sttCredentials = {...this.sttCredentials, access_token};
|
||||
}
|
||||
else if (this.vendor == 'ibm' && this.sttCredentials.stt_api_key) {
|
||||
/* get ibm access token */
|
||||
const {stt_api_key, stt_region} = this.sttCredentials;
|
||||
const {access_token, servedFromCache} = await getIbmAccessToken(stt_api_key);
|
||||
this.logger.debug({stt_api_key}, `Gather:exec - got ibm access token ${servedFromCache ? 'from cache' : ''}`);
|
||||
this.sttCredentials = {...this.sttCredentials, access_token, stt_region};
|
||||
}
|
||||
await this._startTranscribing(cs, ep, 1);
|
||||
if (this.separateRecognitionPerChannel && ep2) await this._startTranscribing(cs, ep2, 2);
|
||||
if (this.separateRecognitionPerChannel && ep2) {
|
||||
await this._startTranscribing(cs, ep2, 2);
|
||||
}
|
||||
|
||||
updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid)
|
||||
.catch(() => {/*already logged error */});
|
||||
@@ -90,139 +129,112 @@ class TaskTranscribe extends Task {
|
||||
this.logger.info(err, 'TaskTranscribe:exec - error');
|
||||
this.parentTask && this.parentTask.emit('error', err);
|
||||
}
|
||||
ep.removeCustomEventListener(GoogleTranscriptionEvents.Transcription);
|
||||
ep.removeCustomEventListener(GoogleTranscriptionEvents.NoAudioDetected);
|
||||
ep.removeCustomEventListener(GoogleTranscriptionEvents.MaxDurationExceeded);
|
||||
ep.removeCustomEventListener(AwsTranscriptionEvents.Transcription);
|
||||
ep.removeCustomEventListener(AwsTranscriptionEvents.NoAudioDetected);
|
||||
ep.removeCustomEventListener(AwsTranscriptionEvents.MaxDurationExceeded);
|
||||
ep.removeCustomEventListener(AzureTranscriptionEvents.Transcription);
|
||||
ep.removeCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected);
|
||||
this.removeSpeechListeners(ep);
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
if (this.ep.connected) {
|
||||
let stopTranscription = false;
|
||||
if (this.ep?.connected) {
|
||||
stopTranscription = true;
|
||||
this.ep.stopTranscription({vendor: this.vendor})
|
||||
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
||||
|
||||
// hangup after 1 sec if we don't get a final transcription
|
||||
this._timer = setTimeout(() => this.notifyTaskDone(), 1000);
|
||||
}
|
||||
if (this.separateRecognitionPerChannel && this.ep2 && this.ep2.connected) {
|
||||
stopTranscription = true;
|
||||
this.ep2.stopTranscription({vendor: this.vendor})
|
||||
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
||||
}
|
||||
// hangup after 1 sec if we don't get a final transcription
|
||||
if (stopTranscription) this._timer = setTimeout(() => this.notifyTaskDone(), 1500);
|
||||
else this.notifyTaskDone();
|
||||
|
||||
await this.awaitTaskDone();
|
||||
}
|
||||
|
||||
async _startTranscribing(cs, ep, channel) {
|
||||
const opts = {};
|
||||
const opts = this.setChannelVarsForStt(this, this.sttCredentials, this.data.recognizer);
|
||||
switch (this.vendor) {
|
||||
case 'google':
|
||||
this.bugname = 'google_transcribe';
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.NoAudioDetected,
|
||||
this._onNoAudio.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.MaxDurationExceeded,
|
||||
this._onMaxDurationExceeded.bind(this, cs, ep, channel));
|
||||
break;
|
||||
|
||||
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;
|
||||
case 'aws':
|
||||
case 'polly':
|
||||
this.bugname = 'aws_transcribe';
|
||||
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, channel));
|
||||
break;
|
||||
case 'microsoft':
|
||||
this.bugname = 'azure_transcribe';
|
||||
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected,
|
||||
this._onNoAudio.bind(this, cs, ep, channel));
|
||||
break;
|
||||
case 'nuance':
|
||||
this.bugname = 'nuance_transcribe';
|
||||
ep.addCustomEventListener(NuanceTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(NuanceTranscriptionEvents.StartOfSpeech,
|
||||
this._onStartOfSpeech.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(NuanceTranscriptionEvents.TranscriptionComplete,
|
||||
this._onTranscriptionComplete.bind(this, cs, ep, channel));
|
||||
break;
|
||||
case 'deepgram':
|
||||
this.bugname = 'deepgram_transcribe';
|
||||
ep.addCustomEventListener(DeepgramTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(DeepgramTranscriptionEvents.Connect,
|
||||
this._onDeepgramConnect.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(DeepgramTranscriptionEvents.ConnectFailure,
|
||||
this._onDeepGramConnectFailure.bind(this, cs, ep, channel));
|
||||
break;
|
||||
case 'soniox':
|
||||
this.bugname = 'soniox_transcribe';
|
||||
ep.addCustomEventListener(SonioxTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
break;
|
||||
case 'ibm':
|
||||
this.bugname = 'ibm_transcribe';
|
||||
ep.addCustomEventListener(IbmTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(IbmTranscriptionEvents.Connect,
|
||||
this._onIbmConnect.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(IbmTranscriptionEvents.ConnectFailure,
|
||||
this._onIbmConnectFailure.bind(this, cs, ep, channel));
|
||||
break;
|
||||
|
||||
case 'nvidia':
|
||||
this.bugname = 'nvidia_transcribe';
|
||||
ep.addCustomEventListener(NvidiaTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(NvidiaTranscriptionEvents.StartOfSpeech,
|
||||
this._onStartOfSpeech.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(NvidiaTranscriptionEvents.TranscriptionComplete,
|
||||
this._onTranscriptionComplete.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(NvidiaTranscriptionEvents.VadDetected,
|
||||
this._onVadDetected.bind(this, cs, ep));
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid vendor ${this.vendor}`);
|
||||
}
|
||||
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.MaxDurationExceeded,
|
||||
this._onMaxDurationExceeded.bind(this, cs, ep, channel));
|
||||
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, channel));
|
||||
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected, this._onNoAudio.bind(this, cs, ep, channel));
|
||||
/* common handler for all stt engine errors */
|
||||
ep.addCustomEventListener(JambonzTranscriptionEvents.Error, this._onJambonzError.bind(this, cs, ep));
|
||||
await ep.set(opts)
|
||||
.catch((err) => this.logger.info(err, 'Error setting channel variables'));
|
||||
|
||||
if (this.vendor === 'google') {
|
||||
if (this.sttCredentials) opts.GOOGLE_APPLICATION_CREDENTIALS = JSON.stringify(this.sttCredentials.credentials);
|
||||
[
|
||||
['enhancedModel', 'GOOGLE_SPEECH_USE_ENHANCED'],
|
||||
//['separateRecognitionPerChannel', 'GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL'],
|
||||
['profanityFilter', 'GOOGLE_SPEECH_PROFANITY_FILTER'],
|
||||
['punctuation', 'GOOGLE_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION'],
|
||||
['words', 'GOOGLE_SPEECH_ENABLE_WORD_TIME_OFFSETS'],
|
||||
['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 (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;
|
||||
}
|
||||
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;
|
||||
|
||||
await ep.set(opts)
|
||||
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with google'));
|
||||
}
|
||||
else if (this.vendor === 'aws') {
|
||||
[
|
||||
['diarization', 'AWS_SHOW_SPEAKER_LABEL'],
|
||||
['identifyChannels', 'AWS_ENABLE_CHANNEL_IDENTIFICATION']
|
||||
].forEach((arr) => {
|
||||
if (this[arr[0]]) opts[arr[1]] = true;
|
||||
});
|
||||
if (this.vocabularyName) opts.AWS_VOCABULARY_NAME = this.vocabularyName;
|
||||
if (this.vocabularyFilterName) {
|
||||
opts.AWS_VOCABULARY_NAME = this.vocabularyFilterName;
|
||||
opts.AWS_VOCABULARY_FILTER_METHOD = this.filterMethod || 'mask';
|
||||
}
|
||||
|
||||
if (this.sttCredentials) {
|
||||
Object.assign(opts, {
|
||||
AWS_ACCESS_KEY_ID: this.sttCredentials.accessKeyId,
|
||||
AWS_SECRET_ACCESS_KEY: this.sttCredentials.secretAccessKey,
|
||||
AWS_REGION: this.sttCredentials.region
|
||||
});
|
||||
}
|
||||
else {
|
||||
Object.assign(opts, {
|
||||
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
AWS_REGION: process.env.AWS_REGION
|
||||
});
|
||||
}
|
||||
|
||||
await ep.set(opts)
|
||||
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with aws'));
|
||||
}
|
||||
else if (this.vendor === 'microsoft') {
|
||||
Object.assign(opts, {
|
||||
'AZURE_SUBSCRIPTION_KEY': this.sttCredentials.api_key,
|
||||
'AZURE_REGION': this.sttCredentials.region
|
||||
});
|
||||
if (this.hints && this.hints.length > 1) {
|
||||
opts.AZURE_SPEECH_HINTS = this.hints.map((h) => h.trim()).join(',');
|
||||
}
|
||||
if (this.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);
|
||||
}
|
||||
|
||||
@@ -231,50 +243,69 @@ 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,
|
||||
bugname: this.bugname
|
||||
});
|
||||
}
|
||||
|
||||
_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];
|
||||
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
|
||||
}
|
||||
];
|
||||
async _onTranscription(cs, ep, channel, evt, fsEvent) {
|
||||
// make sure this is not a transcript from answering machine detection
|
||||
const bugname = fsEvent.getHeader('media-bugname');
|
||||
if (bugname && this.bugname !== bugname) return;
|
||||
|
||||
const newEvent = {
|
||||
is_final: evt.RecognitionStatus === 'Success',
|
||||
channel,
|
||||
language_code,
|
||||
alternatives
|
||||
};
|
||||
evt = newEvent;
|
||||
if (this.vendor === 'ibm') {
|
||||
if (evt?.state === 'listening') return;
|
||||
}
|
||||
this.logger.debug({evt}, 'TaskTranscribe:_onTranscription - before normalization');
|
||||
|
||||
evt = this.normalizeTranscription(evt, this.vendor, channel, this.language);
|
||||
this.logger.debug({evt}, 'TaskTranscribe:_onTranscription');
|
||||
if (evt.alternatives.length === 0) {
|
||||
this.logger.info({evt}, 'TaskTranscribe:_onTranscription - got empty transcript, continue listening');
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.alternatives[0].transcript === '' && !cs.callGone && !this.killed) {
|
||||
this.logger.info({evt}, 'TaskGather:_onTranscription - got empty transcript, listen again');
|
||||
return this._transcribe(ep);
|
||||
if (evt.alternatives[0]?.transcript === '' && !cs.callGone && !this.killed) {
|
||||
if (['microsoft', 'deepgram'].includes(this.vendor)) {
|
||||
this.logger.info({evt}, 'TaskTranscribe:_onTranscription - got empty transcript, continue listening');
|
||||
}
|
||||
else {
|
||||
this.logger.info({evt}, 'TaskTranscribe:_onTranscription - got empty transcript, listen again');
|
||||
this._transcribe(ep);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
evt.channel_tag = channel;
|
||||
if (this.vendor === 'soniox') {
|
||||
/* compile transcripts into one */
|
||||
this._sonioxTranscripts.push(evt.vendor.finalWords);
|
||||
if (evt.is_final) {
|
||||
evt = this.compileSonioxTranscripts(this._sonioxTranscripts, 1, this.language);
|
||||
this._sonioxTranscripts = [];
|
||||
}
|
||||
}
|
||||
|
||||
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'));
|
||||
try {
|
||||
const json = await this.cs.requestor.request('verb:hook', this.transcriptionHook, {
|
||||
...this.cs.callInfo,
|
||||
...httpHeaders,
|
||||
speech: evt
|
||||
});
|
||||
this.logger.info({json}, 'sent transcriptionHook');
|
||||
if (json && Array.isArray(json) && !this.parentTask) {
|
||||
const makeTask = require('./make_task');
|
||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
if (tasks && tasks.length > 0) {
|
||||
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
|
||||
this.cs.replaceApplication(tasks);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'TranscribeTask:_onTranscription error');
|
||||
}
|
||||
}
|
||||
if (this.parentTask) {
|
||||
this.parentTask.emit('transcription', evt);
|
||||
@@ -302,6 +333,64 @@ class TaskTranscribe extends Task {
|
||||
this._timer = null;
|
||||
}
|
||||
}
|
||||
_onDeepgramConnect(_cs, _ep) {
|
||||
this.logger.debug('TaskTranscribe:_onDeepgramConnect');
|
||||
}
|
||||
|
||||
_onDeepGramConnectFailure(cs, _ep, _channel, evt) {
|
||||
const {reason} = evt;
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
this.logger.info({evt}, 'TaskTranscribe:_onDeepgramConnectFailure');
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.STT_FAILURE,
|
||||
message: `Failed connecting to Deepgram speech recognizer: ${reason}`,
|
||||
vendor: 'deepgram',
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for deepgram connection failure'));
|
||||
this.notifyError(`Failed connecting to speech vendor deepgram: ${reason}`);
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
_onIbmConnect(_cs, _ep) {
|
||||
this.logger.debug('TaskTranscribe:_onIbmConnect');
|
||||
}
|
||||
|
||||
_onIbmConnectFailure(cs, _ep, _channel, evt) {
|
||||
const {reason} = evt;
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
this.logger.info({evt}, 'TaskTranscribe:_onIbmConnectFailure');
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.STT_FAILURE,
|
||||
message: `Failed connecting to IBM watson speech recognizer: ${reason}`,
|
||||
vendor: 'ibm',
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for IBM connection failure'));
|
||||
this.notifyError(`Failed connecting to speech vendor IBM: ${reason}`);
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
_onIbmError(cs, _ep, _channel, evt) {
|
||||
this.logger.info({evt}, 'TaskTranscribe:_onIbmError');
|
||||
}
|
||||
_onJambonzError(cs, _ep, evt) {
|
||||
this.logger.info({evt}, 'TaskTranscribe:_onJambonzError');
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
|
||||
if (this.vendor === 'nuance') {
|
||||
const {code, error} = evt;
|
||||
if (code === 404 && error === 'No speech') return this._resolve('timeout');
|
||||
if (code === 413 && error === 'Too much speech') return this._resolve('timeout');
|
||||
}
|
||||
this.logger.info({evt}, 'TaskTranscribe:_onJambonzError');
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.STT_FAILURE,
|
||||
message: `Custom speech vendor ${this.vendor} error: ${evt.error}`,
|
||||
vendor: this.vendor,
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for jambonz custom connection failure'));
|
||||
this.notifyError({msg: 'ASR error', details:`Custom speech vendor ${this.vendor} error: ${evt.error}`});
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
module.exports = TaskTranscribe;
|
||||
|
||||
409
lib/utils/amd-utils.js
Normal file
409
lib/utils/amd-utils.js
Normal file
@@ -0,0 +1,409 @@
|
||||
const Emitter = require('events');
|
||||
const {readFile} = require('fs');
|
||||
const {
|
||||
TaskName,
|
||||
GoogleTranscriptionEvents,
|
||||
AwsTranscriptionEvents,
|
||||
AzureTranscriptionEvents,
|
||||
NuanceTranscriptionEvents,
|
||||
NvidiaTranscriptionEvents,
|
||||
IbmTranscriptionEvents,
|
||||
SonioxTranscriptionEvents,
|
||||
DeepgramTranscriptionEvents,
|
||||
JambonzTranscriptionEvents,
|
||||
AmdEvents,
|
||||
AvmdEvents
|
||||
} = require('./constants');
|
||||
const bugname = 'amd_bug';
|
||||
const {VMD_HINTS_FILE} = require('../config');
|
||||
let voicemailHints = [];
|
||||
|
||||
const updateHints = async(file, callback) => {
|
||||
readFile(file, 'utf8', (err, data) => {
|
||||
if (err) return callback(err);
|
||||
try {
|
||||
callback(null, JSON.parse(data));
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (VMD_HINTS_FILE) {
|
||||
updateHints(VMD_HINTS_FILE, (err, hints) => {
|
||||
if (err) { console.error(err); }
|
||||
voicemailHints = hints;
|
||||
|
||||
/* if successful, update the hints every hour */
|
||||
setInterval(() => {
|
||||
updateHints(VMD_HINTS_FILE, (err, hints) => {
|
||||
if (err) { console.error(err); }
|
||||
voicemailHints = hints;
|
||||
});
|
||||
}, 60000);
|
||||
});
|
||||
}
|
||||
|
||||
class Amd extends Emitter {
|
||||
constructor(logger, cs, opts) {
|
||||
super();
|
||||
this.logger = logger;
|
||||
this.vendor = opts.recognizer?.vendor || cs.speechRecognizerVendor;
|
||||
if ('default' === this.vendor) this.vendor = cs.speechRecognizerVendor;
|
||||
|
||||
this.language = opts.recognizer?.language || cs.speechRecognizerLanguage;
|
||||
if ('default' === this.language) this.language = cs.speechRecognizerLanguage;
|
||||
|
||||
this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt');
|
||||
|
||||
if (!this.sttCredentials) throw new Error(`No speech credentials found for vendor ${this.vendor}`);
|
||||
|
||||
this.thresholdWordCount = opts.thresholdWordCount || 9;
|
||||
const {normalizeTranscription} = require('./transcription-utils')(logger);
|
||||
this.normalizeTranscription = normalizeTranscription;
|
||||
const {getNuanceAccessToken, getIbmAccessToken} = cs.srf.locals.dbHelpers;
|
||||
this.getNuanceAccessToken = getNuanceAccessToken;
|
||||
this.getIbmAccessToken = getIbmAccessToken;
|
||||
const {setChannelVarsForStt} = require('./transcription-utils')(logger);
|
||||
this.setChannelVarsForStt = setChannelVarsForStt;
|
||||
|
||||
const {
|
||||
noSpeechTimeoutMs = 5000,
|
||||
decisionTimeoutMs = 15000,
|
||||
toneTimeoutMs = 20000,
|
||||
greetingCompletionTimeoutMs = 2000
|
||||
} = opts.timers || {};
|
||||
this.noSpeechTimeoutMs = noSpeechTimeoutMs;
|
||||
this.decisionTimeoutMs = decisionTimeoutMs;
|
||||
this.toneTimeoutMs = toneTimeoutMs;
|
||||
this.greetingCompletionTimeoutMs = greetingCompletionTimeoutMs;
|
||||
|
||||
this.beepDetected = false;
|
||||
}
|
||||
|
||||
startDecisionTimer() {
|
||||
this.decisionTimer = setTimeout(this._onDecisionTimeout.bind(this), this.decisionTimeoutMs);
|
||||
this.noSpeechTimer = setTimeout(this._onNoSpeechTimeout.bind(this), this.noSpeechTimeoutMs);
|
||||
this.startToneTimer();
|
||||
}
|
||||
stopDecisionTimer() {
|
||||
this.decisionTimer && clearTimeout(this.decisionTimer);
|
||||
}
|
||||
stopNoSpeechTimer() {
|
||||
this.noSpeechTimer && clearTimeout(this.noSpeechTimer);
|
||||
}
|
||||
startToneTimer() {
|
||||
this.toneTimer = setTimeout(this._onToneTimeout.bind(this), this.toneTimeoutMs);
|
||||
}
|
||||
startGreetingCompletionTimer() {
|
||||
this.greetingCompletionTimer = setTimeout(
|
||||
this._onGreetingCompletionTimeout.bind(this),
|
||||
this.beepDetected ? 1000 : this.greetingCompletionTimeoutMs);
|
||||
}
|
||||
stopGreetingCompletionTimer() {
|
||||
this.greetingCompletionTimer && clearTimeout(this.greetingCompletionTimer);
|
||||
}
|
||||
restartGreetingCompletionTimer() {
|
||||
this.stopGreetingCompletionTimer();
|
||||
this.startGreetingCompletionTimer();
|
||||
}
|
||||
stopToneTimer() {
|
||||
this.toneTimer && clearTimeout(this.toneTimer);
|
||||
}
|
||||
stopAllTimers() {
|
||||
this.stopDecisionTimer();
|
||||
this.stopNoSpeechTimer();
|
||||
this.stopToneTimer();
|
||||
this.stopGreetingCompletionTimer();
|
||||
}
|
||||
_onDecisionTimeout() {
|
||||
this.emit(this.decision = AmdEvents.DecisionTimeout);
|
||||
this.stopNoSpeechTimer();
|
||||
}
|
||||
_onToneTimeout() {
|
||||
this.emit(AmdEvents.ToneTimeout);
|
||||
}
|
||||
_onNoSpeechTimeout() {
|
||||
this.emit(this.decision = AmdEvents.NoSpeechDetected);
|
||||
this.stopDecisionTimer();
|
||||
}
|
||||
_onGreetingCompletionTimeout() {
|
||||
this.emit(AmdEvents.MachineStoppedSpeaking);
|
||||
}
|
||||
|
||||
evaluateTranscription(evt) {
|
||||
if (this.decision) {
|
||||
/* at this point we are only listening for the machine to stop speaking */
|
||||
if (this.decision === AmdEvents.MachineDetected) {
|
||||
this.restartGreetingCompletionTimer();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.stopNoSpeechTimer();
|
||||
|
||||
this.logger.debug({evt}, 'Amd:evaluateTranscription - raw');
|
||||
const t = this.normalizeTranscription(evt, this.vendor, this.language);
|
||||
const hints = voicemailHints[this.language] || [];
|
||||
|
||||
this.logger.debug({t}, 'Amd:evaluateTranscription - normalized');
|
||||
|
||||
if (Array.isArray(t.alternatives) && t.alternatives.length > 0) {
|
||||
const wordCount = t.alternatives[0].transcript.split(' ').length;
|
||||
const final = t.is_final;
|
||||
|
||||
const foundHint = hints.find((h) => t.alternatives[0].transcript.includes(h));
|
||||
if (foundHint) {
|
||||
/* we detected a common voice mail greeting */
|
||||
this.logger.debug(`Amd:evaluateTranscription: found hint ${foundHint}`);
|
||||
this.emit(this.decision = AmdEvents.MachineDetected, {
|
||||
reason: 'hint',
|
||||
hint: foundHint,
|
||||
language: t.language_code
|
||||
});
|
||||
}
|
||||
else if (final && wordCount < this.thresholdWordCount) {
|
||||
/* a short greeting is typically a human */
|
||||
this.emit(this.decision = AmdEvents.HumanDetected, {
|
||||
reason: 'short greeting',
|
||||
greeting: t.alternatives[0].transcript,
|
||||
language: t.language_code
|
||||
});
|
||||
}
|
||||
else if (wordCount >= this.thresholdWordCount) {
|
||||
/* a long greeting is typically a machine */
|
||||
this.emit(this.decision = AmdEvents.MachineDetected, {
|
||||
reason: 'long greeting',
|
||||
greeting: t.alternatives[0].transcript,
|
||||
language: t.language_code
|
||||
});
|
||||
}
|
||||
|
||||
if (this.decision) {
|
||||
this.stopDecisionTimer();
|
||||
|
||||
if (this.decision === AmdEvents.MachineDetected) {
|
||||
/* if we detected a machine, then wait for greeting to end */
|
||||
this.startGreetingCompletionTimer();
|
||||
}
|
||||
}
|
||||
return this.decision;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = (logger) => {
|
||||
const startTranscribing = async(cs, ep, task) => {
|
||||
const {vendor, language} = ep.amd;
|
||||
ep.startTranscription({
|
||||
vendor,
|
||||
locale: language,
|
||||
interim: true,
|
||||
bugname
|
||||
}).catch((err) => {
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
ep.amd = null;
|
||||
task.emit(AmdEvents.Error, err);
|
||||
logger.error(err, 'amd:_startTranscribing error');
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.STT_FAILURE,
|
||||
vendor: vendor,
|
||||
detail: err.message
|
||||
});
|
||||
}).catch((err) => logger.info({err}, 'Error generating alert for tts failure'));
|
||||
|
||||
};
|
||||
|
||||
const onEndOfUtterance = (cs, ep, task) => {
|
||||
logger.debug('amd:onEndOfUtterance');
|
||||
startTranscribing(cs, ep, task);
|
||||
};
|
||||
const onNoSpeechDetected = (cs, ep, task) => {
|
||||
logger.debug('amd:onNoSpeechDetected');
|
||||
ep.amd.stopAllTimers();
|
||||
task.emit(AmdEvents.NoSpeechDetected);
|
||||
};
|
||||
const onTranscription = (cs, ep, task, evt, fsEvent) => {
|
||||
if (fsEvent.getHeader('media-bugname') !== bugname) return;
|
||||
ep.amd?.evaluateTranscription(evt);
|
||||
};
|
||||
const onBeep = (cs, ep, task, evt, fsEvent) => {
|
||||
logger.debug({evt, fsEvent}, 'onBeep');
|
||||
const frequency = Math.floor(fsEvent.getHeader('Frequency'));
|
||||
const variance = Math.floor(fsEvent.getHeader('Frequency-variance'));
|
||||
task.emit('amd', {type: AmdEvents.ToneDetected, frequency, variance});
|
||||
if (ep.amd) {
|
||||
ep.amd.stopToneTimer();
|
||||
ep.amd.beepDetected = true;
|
||||
}
|
||||
ep.execute('avmd_stop').catch((err) => this.logger.info(err, 'Error stopping avmd'));
|
||||
};
|
||||
|
||||
const startAmd = async(cs, ep, task, opts) => {
|
||||
const amd = ep.amd = new Amd(logger, cs, opts);
|
||||
const {vendor, language} = amd;
|
||||
let sttCredentials = amd.sttCredentials;
|
||||
const hints = voicemailHints[language] || [];
|
||||
|
||||
if (vendor === 'nuance' && sttCredentials.client_id) {
|
||||
/* get nuance access token */
|
||||
const {getNuanceAccessToken} = amd;
|
||||
const {client_id, secret} = sttCredentials;
|
||||
const {access_token, servedFromCache} = await getNuanceAccessToken(client_id, secret, 'asr tts');
|
||||
logger.debug({client_id}, `Gather:exec - got nuance access token ${servedFromCache ? 'from cache' : ''}`);
|
||||
sttCredentials = {...sttCredentials, access_token};
|
||||
}
|
||||
else if (vendor == 'ibm' && sttCredentials.stt_api_key) {
|
||||
/* get ibm access token */
|
||||
const {getIbmAccessToken} = amd;
|
||||
const {stt_api_key, stt_region} = sttCredentials;
|
||||
const {access_token, servedFromCache} = await getIbmAccessToken(stt_api_key);
|
||||
logger.debug({stt_api_key}, `Gather:exec - got ibm access token ${servedFromCache ? 'from cache' : ''}`);
|
||||
sttCredentials = {...sttCredentials, access_token, stt_region};
|
||||
}
|
||||
|
||||
/* set stt options */
|
||||
logger.info(`starting amd for vendor ${vendor} and language ${language}`);
|
||||
const sttOpts = amd.setChannelVarsForStt({name: TaskName.Gather}, sttCredentials, {
|
||||
vendor,
|
||||
hints,
|
||||
enhancedModel: true,
|
||||
altLanguages: opts.recognizer?.altLanguages || [],
|
||||
initialSpeechTimeoutMs: opts.resolveTimeoutMs,
|
||||
});
|
||||
|
||||
await ep.set(sttOpts).catch((err) => logger.info(err, 'Error setting channel variables'));
|
||||
|
||||
amd.transcriptionHandler = onTranscription.bind(null, cs, ep, task);
|
||||
amd.EndOfUtteranceHandler = onEndOfUtterance.bind(null, cs, ep, task);
|
||||
amd.noSpeechHandler = onNoSpeechDetected.bind(null, cs, ep, task);
|
||||
|
||||
switch (vendor) {
|
||||
case 'google':
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, amd.transcriptionHandler);
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance, amd.EndOfUtteranceHandler);
|
||||
break;
|
||||
|
||||
case 'aws':
|
||||
case 'polly':
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, amd.transcriptionHandler);
|
||||
break;
|
||||
case 'microsoft':
|
||||
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription, amd.transcriptionHandler);
|
||||
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected, amd.noSpeechHandler);
|
||||
break;
|
||||
case 'nuance':
|
||||
ep.addCustomEventListener(NuanceTranscriptionEvents.Transcription, amd.transcriptionHandler);
|
||||
break;
|
||||
|
||||
case 'deepgram':
|
||||
ep.addCustomEventListener(DeepgramTranscriptionEvents.Transcription, amd.transcriptionHandler);
|
||||
break;
|
||||
|
||||
case 'soniox':
|
||||
amd.bugname = 'soniox_amd_transcribe';
|
||||
ep.addCustomEventListener(SonioxTranscriptionEvents.Transcription, amd.transcriptionHandler);
|
||||
break;
|
||||
|
||||
case 'ibm':
|
||||
ep.addCustomEventListener(IbmTranscriptionEvents.Transcription, amd.transcriptionHandler);
|
||||
break;
|
||||
|
||||
case 'nvidia':
|
||||
ep.addCustomEventListener(NvidiaTranscriptionEvents.Transcription, amd.transcriptionHandler);
|
||||
break;
|
||||
|
||||
default:
|
||||
if (vendor.startsWith('custom:')) {
|
||||
ep.addCustomEventListener(JambonzTranscriptionEvents.Transcription, amd.transcriptionHandler);
|
||||
break;
|
||||
}
|
||||
else {
|
||||
throw new Error(`Invalid vendor ${this.vendor}`);
|
||||
}
|
||||
}
|
||||
amd
|
||||
.on(AmdEvents.NoSpeechDetected, (evt) => {
|
||||
task.emit('amd', {type: AmdEvents.NoSpeechDetected, ...evt});
|
||||
try {
|
||||
stopAmd(ep, task);
|
||||
} catch (err) {
|
||||
logger.info({err}, 'Error stopping transcription');
|
||||
}
|
||||
})
|
||||
.on(AmdEvents.HumanDetected, (evt) => {
|
||||
task.emit('amd', {type: AmdEvents.HumanDetected, ...evt});
|
||||
try {
|
||||
stopAmd(ep, task);
|
||||
} catch (err) {
|
||||
logger.info({err}, 'Error stopping transcription');
|
||||
}
|
||||
})
|
||||
.on(AmdEvents.MachineDetected, (evt) => {
|
||||
task.emit('amd', {type: AmdEvents.MachineDetected, ...evt});
|
||||
})
|
||||
.on(AmdEvents.DecisionTimeout, (evt) => {
|
||||
task.emit('amd', {type: AmdEvents.DecisionTimeout, ...evt});
|
||||
try {
|
||||
stopAmd(ep, task);
|
||||
} catch (err) {
|
||||
logger.info({err}, 'Error stopping transcription');
|
||||
}
|
||||
})
|
||||
.on(AmdEvents.ToneTimeout, (evt) => {
|
||||
//task.emit('amd', {type: AmdEvents.ToneTimeout, ...evt});
|
||||
try {
|
||||
stopAmd(ep, task);
|
||||
} catch (err) {
|
||||
logger.info({err}, 'Error stopping avmd');
|
||||
}
|
||||
})
|
||||
.on(AmdEvents.MachineStoppedSpeaking, () => {
|
||||
task.emit('amd', {type: AmdEvents.MachineStoppedSpeaking});
|
||||
try {
|
||||
stopAmd(ep, task);
|
||||
} catch (err) {
|
||||
logger.info({err}, 'Error stopping transcription');
|
||||
}
|
||||
});
|
||||
|
||||
/* start transcribing, and also listening for beep */
|
||||
amd.startDecisionTimer();
|
||||
startTranscribing(cs, ep, task);
|
||||
|
||||
ep.addCustomEventListener(AvmdEvents.Beep, onBeep.bind(null, cs, ep, task));
|
||||
ep.execute('avmd_start').catch((err) => this.logger.info(err, 'Error starting avmd'));
|
||||
};
|
||||
|
||||
const stopAmd = (ep, task) => {
|
||||
let vendor;
|
||||
if (ep.amd) {
|
||||
vendor = ep.amd.vendor;
|
||||
ep.amd.stopAllTimers();
|
||||
|
||||
ep.removeListener(GoogleTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||
ep.removeListener(GoogleTranscriptionEvents.EndOfUtterance, ep.amd.EndOfUtteranceHandler);
|
||||
ep.removeListener(AwsTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||
ep.removeListener(AzureTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||
ep.removeListener(AzureTranscriptionEvents.NoSpeechDetected, ep.amd.noSpeechHandler);
|
||||
ep.removeListener(NuanceTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||
ep.removeListener(DeepgramTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||
ep.removeListener(SonioxTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||
ep.removeListener(IbmTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||
ep.removeListener(NvidiaTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||
ep.removeListener(JambonzTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||
|
||||
ep.amd = null;
|
||||
}
|
||||
|
||||
if (ep.connected) {
|
||||
ep.stopTranscription({vendor, bugname})
|
||||
.catch((err) => logger.info(err, 'stopAmd: Error stopping transcription'));
|
||||
task.emit('amd', {type: AmdEvents.Stopped});
|
||||
ep.execute('avmd_stop').catch((err) => this.logger.info(err, 'Error stopping avmd'));
|
||||
}
|
||||
ep.removeCustomEventListener(AvmdEvents.Beep);
|
||||
};
|
||||
|
||||
return {startAmd, stopAmd};
|
||||
};
|
||||
@@ -1,7 +1,12 @@
|
||||
const Emitter = require('events');
|
||||
const bent = require('bent');
|
||||
const assert = require('assert');
|
||||
const PORT = process.env.AWS_SNS_PORT || 3001;
|
||||
const {
|
||||
AWS_REGION,
|
||||
AWS_SNS_PORT: PORT,
|
||||
AWS_SNS_TOPIC_ARM,
|
||||
AWS_SNS_PORT_MAX,
|
||||
} = require('../config');
|
||||
const {LifeCycleEvents} = require('./constants');
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
@@ -13,7 +18,7 @@ const {Parser} = require('xml2js');
|
||||
const parser = new Parser();
|
||||
const {validatePayload} = require('verify-aws-sns-signature');
|
||||
|
||||
AWS.config.update({region: process.env.AWS_REGION});
|
||||
AWS.config.update({region: AWS_REGION});
|
||||
|
||||
class SnsNotifier extends Emitter {
|
||||
constructor(logger) {
|
||||
@@ -21,6 +26,26 @@ class SnsNotifier extends Emitter {
|
||||
|
||||
this.logger = logger;
|
||||
}
|
||||
_doListen(logger, app, port, resolve) {
|
||||
return app.listen(port, () => {
|
||||
this.snsEndpoint = `http://${this.publicIp}:${port}`;
|
||||
logger.info(`SNS lifecycle server listening on http://localhost:${port}`);
|
||||
resolve(app);
|
||||
});
|
||||
}
|
||||
|
||||
_handleErrors(logger, app, resolve, reject, e) {
|
||||
if (e.code === 'EADDRINUSE' &&
|
||||
AWS_SNS_PORT_MAX &&
|
||||
e.port < AWS_SNS_PORT_MAX) {
|
||||
|
||||
logger.info(`SNS lifecycle server failed to bind port on ${e.port}, will try next port`);
|
||||
const server = this._doListen(logger, app, ++e.port, resolve);
|
||||
server.on('error', this._handleErrors.bind(this, logger, app, resolve, reject));
|
||||
return;
|
||||
}
|
||||
reject(e);
|
||||
}
|
||||
|
||||
async _handlePost(req, res) {
|
||||
try {
|
||||
@@ -84,11 +109,9 @@ class SnsNotifier extends Emitter {
|
||||
this.logger.debug('SnsNotifier: retrieving instance data');
|
||||
this.instanceId = await getString('http://169.254.169.254/latest/meta-data/instance-id');
|
||||
this.publicIp = await getString('http://169.254.169.254/latest/meta-data/public-ipv4');
|
||||
this.snsEndpoint = `http://${this.publicIp}:${PORT}`;
|
||||
this.logger.info({
|
||||
instanceId: this.instanceId,
|
||||
publicIp: this.publicIp,
|
||||
snsEndpoint: this.snsEndpoint
|
||||
publicIp: this.publicIp
|
||||
}, 'retrieved AWS instance data');
|
||||
|
||||
// start listening
|
||||
@@ -100,7 +123,10 @@ class SnsNotifier extends Emitter {
|
||||
this.logger.error(err, 'burped error');
|
||||
res.status(err.status || 500).json({msg: err.message});
|
||||
});
|
||||
app.listen(PORT);
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = this._doListen(this.logger, app, PORT, resolve);
|
||||
server.on('error', this._handleErrors.bind(this, this.logger, app, resolve, reject));
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'Error retrieving AWS instance metadata');
|
||||
@@ -111,12 +137,12 @@ class SnsNotifier extends Emitter {
|
||||
try {
|
||||
const response = await sns.subscribe({
|
||||
Protocol: 'http',
|
||||
TopicArn: process.env.AWS_SNS_TOPIC_ARM,
|
||||
TopicArn: AWS_SNS_TOPIC_ARM,
|
||||
Endpoint: this.snsEndpoint
|
||||
}).promise();
|
||||
this.logger.info({response}, `response to SNS subscribe to ${process.env.AWS_SNS_TOPIC_ARM}`);
|
||||
this.logger.info({response}, `response to SNS subscribe to ${AWS_SNS_TOPIC_ARM}`);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, `Error subscribing to SNS topic arn ${process.env.AWS_SNS_TOPIC_ARM}`);
|
||||
this.logger.error({err}, `Error subscribing to SNS topic arn ${AWS_SNS_TOPIC_ARM}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,9 +152,9 @@ class SnsNotifier extends Emitter {
|
||||
const response = await sns.unsubscribe({
|
||||
SubscriptionArn: this.subscriptionArn
|
||||
}).promise();
|
||||
this.logger.info({response}, `response to SNS unsubscribe to ${process.env.AWS_SNS_TOPIC_ARM}`);
|
||||
this.logger.info({response}, `response to SNS unsubscribe to ${AWS_SNS_TOPIC_ARM}`);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, `Error unsubscribing to SNS topic arn ${process.env.AWS_SNS_TOPIC_ARM}`);
|
||||
this.logger.error({err}, `Error unsubscribing to SNS topic arn ${AWS_SNS_TOPIC_ARM}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ const assert = require('assert');
|
||||
const Emitter = require('events');
|
||||
const crypto = require('crypto');
|
||||
const timeSeries = require('@jambonz/time-series');
|
||||
const {NODE_ENV, JAMBONES_TIME_SERIES_HOST} = require('../config');
|
||||
let alerter ;
|
||||
|
||||
class BaseRequestor extends Emitter {
|
||||
@@ -22,9 +23,9 @@ class BaseRequestor extends Emitter {
|
||||
|
||||
if (!alerter) {
|
||||
alerter = timeSeries(logger, {
|
||||
host: process.env.JAMBONES_TIME_SERIES_HOST,
|
||||
host: JAMBONES_TIME_SERIES_HOST,
|
||||
commitSize: 50,
|
||||
commitInterval: 'test' === process.env.NODE_ENV ? 7 : 20
|
||||
commitInterval: 'test' === NODE_ENV ? 7 : 20
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"Tag": "tag",
|
||||
"Transcribe": "transcribe"
|
||||
},
|
||||
"AllowedSipRecVerbs": ["config", "gather", "transcribe", "listen"],
|
||||
"CallStatus": {
|
||||
"Trying": "trying",
|
||||
"Ringing": "ringing",
|
||||
@@ -56,6 +57,9 @@
|
||||
"StableCall": "stable-call",
|
||||
"UnansweredCall": "unanswered-call"
|
||||
},
|
||||
"AvmdEvents": {
|
||||
"Beep": "avmd::beep"
|
||||
},
|
||||
"GoogleTranscriptionEvents": {
|
||||
"Transcription": "google_transcribe::transcription",
|
||||
"EndOfUtterance": "google_transcribe::end_of_utterance",
|
||||
@@ -63,6 +67,35 @@
|
||||
"MaxDurationExceeded": "google_transcribe::max_duration_exceeded",
|
||||
"VadDetected": "google_transcribe::vad_detected"
|
||||
},
|
||||
"NuanceTranscriptionEvents": {
|
||||
"Transcription": "nuance_transcribe::transcription",
|
||||
"StartOfSpeech": "nuance_transcribe::start_of_speech",
|
||||
"TranscriptionComplete": "nuance_transcribe::end_of_transcription",
|
||||
"Error": "nuance_transcribe::error",
|
||||
"VadDetected": "nuance_transcribe::vad_detected"
|
||||
},
|
||||
"NvidiaTranscriptionEvents": {
|
||||
"Transcription": "nvidia_transcribe::transcription",
|
||||
"StartOfSpeech": "nvidia_transcribe::start_of_speech",
|
||||
"TranscriptionComplete": "nvidia_transcribe::end_of_transcription",
|
||||
"Error": "nvidia_transcribe::error",
|
||||
"VadDetected": "nvidia_transcribe::vad_detected"
|
||||
},
|
||||
"DeepgramTranscriptionEvents": {
|
||||
"Transcription": "deepgram_transcribe::transcription",
|
||||
"ConnectFailure": "deepgram_transcribe::connect_failed",
|
||||
"Connect": "deepgram_transcribe::connect"
|
||||
},
|
||||
"SonioxTranscriptionEvents": {
|
||||
"Transcription": "soniox_transcribe::transcription",
|
||||
"Error": "soniox_transcribe::error"
|
||||
},
|
||||
"IbmTranscriptionEvents": {
|
||||
"Transcription": "ibm_transcribe::transcription",
|
||||
"ConnectFailure": "ibm_transcribe::connect_failed",
|
||||
"Connect": "ibm_transcribe::connect",
|
||||
"Error": "ibm_transcribe::error"
|
||||
},
|
||||
"AwsTranscriptionEvents": {
|
||||
"Transcription": "aws_transcribe::transcription",
|
||||
"EndOfTranscript": "aws_transcribe::end_of_transcript",
|
||||
@@ -77,6 +110,12 @@
|
||||
"NoSpeechDetected": "azure_transcribe::no_speech_detected",
|
||||
"VadDetected": "azure_transcribe::vad_detected"
|
||||
},
|
||||
"JambonzTranscriptionEvents": {
|
||||
"Transcription": "jambonz_transcribe::transcription",
|
||||
"ConnectFailure": "jambonz_transcribe::connect_failed",
|
||||
"Connect": "jambonz_transcribe::connect",
|
||||
"Error": "jambonz_transcribe::error"
|
||||
},
|
||||
"ListenEvents": {
|
||||
"Connect": "mod_audio_fork::connect",
|
||||
"ConnectFailure": "mod_audio_fork::connect_failed",
|
||||
@@ -118,6 +157,7 @@
|
||||
"queue:status",
|
||||
"dial:confirm",
|
||||
"verb:hook",
|
||||
"verb:status",
|
||||
"jambonz:error"
|
||||
],
|
||||
"RecordState": {
|
||||
@@ -125,6 +165,17 @@
|
||||
"RecordingOff": "recording_off",
|
||||
"RecordingPaused": "recording_paused"
|
||||
},
|
||||
"AmdEvents": {
|
||||
"NoSpeechDetected": "amd_no_speech_detected",
|
||||
"HumanDetected": "amd_human_detected",
|
||||
"MachineDetected": "amd_machine_detected",
|
||||
"MachineStoppedSpeaking": "amd_machine_stopped_speaking",
|
||||
"Error": "amd_error",
|
||||
"DecisionTimeout": "amd_decision_timeout",
|
||||
"ToneDetected": "amd_tone_detected",
|
||||
"ToneTimeout": "amd_tone_timeout",
|
||||
"Stopped": "amd_stopped"
|
||||
},
|
||||
"MAX_SIMRINGS": 10,
|
||||
"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"
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
const {execSync} = require('child_process');
|
||||
const {
|
||||
JAMBONES_FREESWITCH,
|
||||
NODE_ENV,
|
||||
JAMBONES_FREESWITCH_MAX_CALL_DURATION_MINS,
|
||||
} = require('../config');
|
||||
const now = Date.now();
|
||||
const fsInventory = process.env.JAMBONES_FREESWITCH
|
||||
const fsInventory = 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';
|
||||
if (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 maxDurationMins = JAMBONES_FREESWITCH_MAX_CALL_DURATION_MINS;
|
||||
|
||||
const calls = execSync(`/usr/local/freeswitch/bin/fs_cli -p ${pwd} -x "show calls"`, {encoding: 'utf8'})
|
||||
.split('\n')
|
||||
@@ -42,9 +47,9 @@ const clearChannels = () => {
|
||||
};
|
||||
|
||||
const clearFiles = () => {
|
||||
const {logger} = require('../..');
|
||||
const out = execSync('find /tmp -name "*.mp3" -mtime +2 -exec rm {} \\;');
|
||||
logger.debug({out}, 'clearFiles: command output');
|
||||
//const {logger} = require('../..');
|
||||
/*const out = */ execSync('find /tmp -name "*.mp3" -mtime +2 -exec rm {} \\;');
|
||||
//logger.debug({out}, 'clearFiles: command output');
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -20,25 +20,76 @@ WHERE vc.account_sid IS NULL
|
||||
AND vc.service_provider_sid =
|
||||
(SELECT service_provider_sid from accounts where account_sid = ?)
|
||||
AND vc.name = ?`;
|
||||
const sqlQueryAccountPhoneNumber = `SELECT voip_carrier_sid
|
||||
FROM phone_numbers pn
|
||||
WHERE pn.account_sid = ?
|
||||
AND pn.number = ?`;
|
||||
const sqlQuerySPPhoneNumber = `SELECT voip_carrier_sid
|
||||
FROM phone_numbers pn
|
||||
WHERE pn.account_sid IS NULL
|
||||
AND pn.service_provider_sid =
|
||||
(SELECT service_provider_sid from accounts where account_sid = ?)
|
||||
AND pn.number = ?`;
|
||||
|
||||
const speechMapper = (cred) => {
|
||||
const {credential, ...obj} = cred;
|
||||
if ('google' === obj.vendor) {
|
||||
obj.service_key = decrypt(credential);
|
||||
}
|
||||
else if ('aws' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
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;
|
||||
try {
|
||||
if ('google' === obj.vendor) {
|
||||
obj.service_key = decrypt(credential);
|
||||
}
|
||||
else if ('aws' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.access_key_id = o.access_key_id;
|
||||
obj.secret_access_key = o.secret_access_key;
|
||||
obj.aws_region = o.aws_region;
|
||||
}
|
||||
else if ('microsoft' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.region = o.region;
|
||||
obj.use_custom_stt = o.use_custom_stt;
|
||||
obj.custom_stt_endpoint = o.custom_stt_endpoint;
|
||||
obj.use_custom_tts = o.use_custom_tts;
|
||||
obj.custom_tts_endpoint = o.custom_tts_endpoint;
|
||||
}
|
||||
else if ('wellsaid' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
}
|
||||
else if ('nuance' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.client_id = o.client_id;
|
||||
obj.secret = o.secret;
|
||||
obj.nuance_tts_uri = o.nuance_tts_uri;
|
||||
obj.nuance_stt_uri = o.nuance_stt_uri;
|
||||
}
|
||||
else if ('ibm' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.tts_api_key = o.tts_api_key;
|
||||
obj.tts_region = o.tts_region;
|
||||
obj.stt_api_key = o.stt_api_key;
|
||||
obj.stt_region = o.stt_region;
|
||||
}
|
||||
else if ('deepgram' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
}
|
||||
else if ('soniox' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
}
|
||||
else if ('nvidia' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.riva_server_uri = o.riva_server_uri;
|
||||
}
|
||||
else if (obj.vendor.startsWith('custom:')) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.auth_token = o.auth_token;
|
||||
obj.custom_stt_url = o.custom_stt_url;
|
||||
obj.custom_tts_url = o.custom_tts_url;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
@@ -49,37 +100,18 @@ module.exports = (logger, srf) => {
|
||||
|
||||
const lookupAccountDetails = async(account_sid) => {
|
||||
|
||||
const [r] = await pp.query({sql: sqlAccountDetails, nestTables: true}, account_sid);
|
||||
const [r] = await pp.query({sql: sqlAccountDetails, nestTables: true}, [account_sid]);
|
||||
if (0 === r.length) throw new Error(`invalid accountSid: ${account_sid}`);
|
||||
const [r2] = await pp.query(sqlSpeechCredentials, account_sid);
|
||||
const [r2] = await pp.query(sqlSpeechCredentials, [account_sid]);
|
||||
const speech = r2.map(speechMapper);
|
||||
|
||||
/* 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');
|
||||
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) {
|
||||
const google = r3.find((s) => s.vendor === 'google');
|
||||
if (google) speech.push(speechMapper(google));
|
||||
}
|
||||
if (!haveAws) {
|
||||
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));
|
||||
}
|
||||
/* add service provider creds unless we have that vendor at the account level */
|
||||
const [r3] = await pp.query(sqlSpeechCredentialsForSP, [account_sid]);
|
||||
r3.forEach((s) => {
|
||||
if (!speech.find((s2) => s2.vendor === s.vendor)) {
|
||||
speech.push(speechMapper(s));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...r[0],
|
||||
@@ -88,6 +120,7 @@ module.exports = (logger, srf) => {
|
||||
};
|
||||
|
||||
const updateSpeechCredentialLastUsed = async(speech_credential_sid) => {
|
||||
if (!speech_credential_sid) return;
|
||||
const pp = pool.promise();
|
||||
const sql = 'UPDATE speech_credentials SET last_used = NOW() WHERE speech_credential_sid = ?';
|
||||
try {
|
||||
@@ -109,9 +142,22 @@ module.exports = (logger, srf) => {
|
||||
}
|
||||
};
|
||||
|
||||
const lookupCarrierByPhoneNumber = async(account_sid, phoneNumber) => {
|
||||
const pp = pool.promise();
|
||||
try {
|
||||
const [r] = await pp.query(sqlQueryAccountPhoneNumber, [account_sid, phoneNumber]);
|
||||
if (r.length) return r[0].voip_carrier_sid;
|
||||
const [r2] = await pp.query(sqlQuerySPPhoneNumber, [account_sid, phoneNumber]);
|
||||
if (r2.length) return r2[0].voip_carrier_sid;
|
||||
} catch (err) {
|
||||
logger.error({err}, `lookupPhoneNumber: Error ${account_sid}:${phoneNumber}`);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
lookupAccountDetails,
|
||||
updateSpeechCredentialLastUsed,
|
||||
lookupCarrier
|
||||
lookupCarrier,
|
||||
lookupCarrierByPhoneNumber
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
const crypto = require('crypto');
|
||||
const algorithm = process.env.LEGACY_CRYPTO ? 'aes-256-ctr' : 'aes-256-cbc';
|
||||
const {LEGACY_CRYPTO, ENCRYPTION_SECRET, JWT_SECRET} = require('../config');
|
||||
const algorithm = LEGACY_CRYPTO ? 'aes-256-ctr' : 'aes-256-cbc';
|
||||
const iv = crypto.randomBytes(16);
|
||||
const secretKey = crypto.createHash('sha256')
|
||||
.update(String(process.env.JWT_SECRET))
|
||||
.update(ENCRYPTION_SECRET || JWT_SECRET)
|
||||
.digest('base64')
|
||||
.substr(0, 32);
|
||||
.substring(0, 32);
|
||||
|
||||
const encrypt = (text) => {
|
||||
const cipher = crypto.createCipheriv(algorithm, secretKey, iv);
|
||||
@@ -25,8 +26,8 @@ const decrypt = (data) => {
|
||||
throw err;
|
||||
}
|
||||
const decipher = crypto.createDecipheriv(algorithm, secretKey, Buffer.from(hash.iv, 'hex'));
|
||||
const decrpyted = Buffer.concat([decipher.update(Buffer.from(hash.content, 'hex')), decipher.final()]);
|
||||
return decrpyted.toString();
|
||||
const decrypted = Buffer.concat([decipher.update(Buffer.from(hash.content, 'hex')), decipher.final()]);
|
||||
return decrypted.toString();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
||||
45
lib/utils/http-listener.js
Normal file
45
lib/utils/http-listener.js
Normal file
@@ -0,0 +1,45 @@
|
||||
|
||||
const express = require('express');
|
||||
const httpRoutes = require('../http-routes');
|
||||
const {PORT, HTTP_PORT_MAX} = require('../config');
|
||||
|
||||
const doListen = (logger, app, port, resolve) => {
|
||||
const server = app.listen(port, () => {
|
||||
const {srf} = app.locals;
|
||||
logger.info(`listening for HTTP requests on port ${PORT}, serviceUrl is ${srf.locals.serviceUrl}`);
|
||||
resolve({server, app});
|
||||
});
|
||||
return server;
|
||||
};
|
||||
const handleErrors = (logger, app, resolve, reject, e) => {
|
||||
if (e.code === 'EADDRINUSE' &&
|
||||
HTTP_PORT_MAX &&
|
||||
e.port < HTTP_PORT_MAX) {
|
||||
|
||||
logger.info(`HTTP server failed to bind port on ${e.port}, will try next port`);
|
||||
const server = doListen(logger, app, ++e.port, resolve);
|
||||
server.on('error', handleErrors.bind(null, logger, app, resolve, reject));
|
||||
return;
|
||||
}
|
||||
logger.info({err: e, port: PORT}, 'httpListener error');
|
||||
reject(e);
|
||||
};
|
||||
|
||||
const createHttpListener = (logger, srf) => {
|
||||
const app = express();
|
||||
app.locals = {...app.locals, logger, srf};
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.use('/', httpRoutes);
|
||||
app.use((err, _req, res, _next) => {
|
||||
logger.error(err, 'burped error');
|
||||
res.status(err.status || 500).json({msg: err.message});
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = doListen(logger, app, PORT, resolve);
|
||||
server.on('error', handleErrors.bind(null, logger, app, resolve, reject));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
module.exports = createHttpListener;
|
||||
@@ -1,9 +1,16 @@
|
||||
const bent = require('bent');
|
||||
const {Client, Pool} = require('undici');
|
||||
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 pools = new Map();
|
||||
const {
|
||||
HTTP_POOL,
|
||||
HTTP_POOLSIZE,
|
||||
HTTP_PIPELINING,
|
||||
HTTP_TIMEOUT,
|
||||
} = require('../config');
|
||||
|
||||
const toBase64 = (str) => Buffer.from(str || '', 'utf8').toString('base64');
|
||||
|
||||
@@ -22,21 +29,47 @@ class HttpRequestor extends BaseRequestor {
|
||||
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}`;
|
||||
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));
|
||||
|
||||
const u = this._parsedUrl = parseUrl(this.url);
|
||||
if (u.port) this._baseUrl = `${u.protocol}://${u.resource}:${u.port}`;
|
||||
else this._baseUrl = `${u.protocol}://${u.resource}`;
|
||||
this._protocol = u.protocol;
|
||||
this._resource = u.resource;
|
||||
this._port = u.port;
|
||||
this._search = u.search;
|
||||
this._usePools = HTTP_POOL && parseInt(HTTP_POOL);
|
||||
|
||||
if (this._usePools) {
|
||||
if (pools.has(this._baseUrl)) {
|
||||
this.client = pools.get(this._baseUrl);
|
||||
}
|
||||
else {
|
||||
const connections = HTTP_POOLSIZE ? parseInt(HTTP_POOLSIZE) : 10;
|
||||
const pipelining = HTTP_PIPELINING ? parseInt(HTTP_PIPELINING) : 1;
|
||||
const pool = this.client = new Pool(this._baseUrl, {
|
||||
connections,
|
||||
pipelining
|
||||
});
|
||||
pools.set(this._baseUrl, pool);
|
||||
this.logger.debug(`HttpRequestor:created pool for ${this._baseUrl}`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (u.port) this.client = new Client(`${u.protocol}://${u.resource}:${u.port}`);
|
||||
else this.client = new Client(`${u.protocol}://${u.resource}`);
|
||||
}
|
||||
}
|
||||
|
||||
get baseUrl() {
|
||||
return this._baseUrl;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this._usePools && !this.client?.closed) this.client.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an HTTP request.
|
||||
* All requests use json bodies.
|
||||
@@ -57,21 +90,73 @@ class HttpRequestor extends BaseRequestor {
|
||||
const payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null;
|
||||
const url = hook.url || hook;
|
||||
const method = hook.method || 'POST';
|
||||
let buf = '';
|
||||
|
||||
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;
|
||||
/* if we have an absolute url, and it is ws then do a websocket connection */
|
||||
if (this._isAbsoluteUrl(url) && url.startsWith('ws')) {
|
||||
const WsRequestor = require('./ws-requestor');
|
||||
this.logger.debug({hook}, 'HttpRequestor: switching to websocket connection');
|
||||
const h = typeof hook === 'object' ? hook : {url: hook};
|
||||
const requestor = new WsRequestor(this.logger, this.account_sid, h, this.secret);
|
||||
if (type === 'session:redirect') {
|
||||
this.close();
|
||||
this.emit('handover', requestor);
|
||||
}
|
||||
return requestor.request('session:new', hook, params, httpHeaders);
|
||||
}
|
||||
|
||||
let newClient;
|
||||
try {
|
||||
let client, path, query;
|
||||
if (this._isRelativeUrl(url)) {
|
||||
client = this.client;
|
||||
path = url;
|
||||
}
|
||||
else {
|
||||
const u = parseUrl(url);
|
||||
if (u.resource === this._resource && u.port === this._port && u.protocol === this._protocol) {
|
||||
client = this.client;
|
||||
path = u.pathname;
|
||||
query = u.query;
|
||||
}
|
||||
else {
|
||||
if (u.port) client = newClient = new Client(`${u.protocol}://${u.resource}:${u.port}`);
|
||||
else client = newClient = new Client(`${u.protocol}://${u.resource}`);
|
||||
path = u.pathname;
|
||||
query = u.query;
|
||||
}
|
||||
}
|
||||
const sigHeader = this._generateSigHeader(payload, this.secret);
|
||||
const 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);
|
||||
const hdrs = {
|
||||
...sigHeader,
|
||||
...this.authHeader,
|
||||
...httpHeaders,
|
||||
...('POST' === method && {'Content-Type': 'application/json'})
|
||||
};
|
||||
const absUrl = this._isRelativeUrl(url) ? `${this.baseUrl}${url}` : url;
|
||||
this.logger.debug({url, absUrl, hdrs}, 'send webhook');
|
||||
const {statusCode, headers, body} = await client.request({
|
||||
path,
|
||||
query,
|
||||
method,
|
||||
headers: hdrs,
|
||||
...('POST' === method && {body: JSON.stringify(payload)}),
|
||||
timeout: HTTP_TIMEOUT,
|
||||
followRedirects: false
|
||||
});
|
||||
if (![200, 202, 204].includes(statusCode)) {
|
||||
const err = new Error();
|
||||
err.statusCode = statusCode;
|
||||
throw err;
|
||||
}
|
||||
if (headers['content-type']?.includes('application/json')) {
|
||||
buf = await body.json();
|
||||
}
|
||||
if (newClient) newClient.close();
|
||||
} catch (err) {
|
||||
if (err.statusCode) {
|
||||
this.logger.info({baseUrl: this.baseUrl, url},
|
||||
@@ -93,20 +178,15 @@ class HttpRequestor extends BaseRequestor {
|
||||
}
|
||||
this.Alerter.writeAlerts(opts).catch((err) => this.logger.info({err, opts}, 'Error writing alert'));
|
||||
|
||||
if (newClient) newClient.close();
|
||||
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()}'`);
|
||||
}
|
||||
if (buf && Array.isArray(buf)) {
|
||||
this.logger.info({response: buf}, `HttpRequestor:request ${method} ${url} succeeded in ${rtt}ms`);
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
const Mrf = require('drachtio-fsmrf');
|
||||
const ip = require('ip');
|
||||
const PORT = process.env.HTTP_PORT || 3000;
|
||||
const {
|
||||
JAMBONES_MYSQL_HOST,
|
||||
JAMBONES_MYSQL_USER,
|
||||
JAMBONES_MYSQL_PASSWORD,
|
||||
JAMBONES_MYSQL_DATABASE,
|
||||
JAMBONES_MYSQL_CONNECTION_LIMIT,
|
||||
JAMBONES_MYSQL_PORT,
|
||||
JAMBONES_FREESWITCH,
|
||||
JAMBONES_REDIS_HOST,
|
||||
JAMBONES_REDIS_PORT,
|
||||
SMPP_URL,
|
||||
JAMBONES_TIME_SERIES_HOST,
|
||||
JAMBONES_ESL_LISTEN_ADDRESS,
|
||||
PORT,
|
||||
NODE_ENV,
|
||||
} = require('../config');
|
||||
const assert = require('assert');
|
||||
|
||||
function initMS(logger, wrapper, ms) {
|
||||
@@ -42,18 +57,18 @@ function installSrfLocals(srf, logger) {
|
||||
let idxStart = 0;
|
||||
|
||||
(async function() {
|
||||
const fsInventory = process.env.JAMBONES_FREESWITCH
|
||||
const fsInventory = JAMBONES_FREESWITCH
|
||||
.split(',')
|
||||
.map((fs) => {
|
||||
const arr = /^([^:]*):([^:]*):([^:]*)(?::([^:]*))?/.exec(fs);
|
||||
assert.ok(arr, `Invalid syntax JAMBONES_FREESWITCH: ${process.env.JAMBONES_FREESWITCH}`);
|
||||
assert.ok(arr, `Invalid syntax JAMBONES_FREESWITCH: ${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;
|
||||
if (NODE_ENV === 'test') opts.listenAddress = '0.0.0.0';
|
||||
else if (JAMBONES_ESL_LISTEN_ADDRESS) opts.listenAddress = JAMBONES_ESL_LISTEN_ADDRESS;
|
||||
return opts;
|
||||
});
|
||||
logger.info({fsInventory}, 'freeswitch inventory');
|
||||
@@ -125,12 +140,12 @@ function installSrfLocals(srf, logger) {
|
||||
lookupAccountCapacitiesBySid,
|
||||
lookupSmppGateways
|
||||
} = require('@jambonz/db-helpers')({
|
||||
host: process.env.JAMBONES_MYSQL_HOST,
|
||||
user: process.env.JAMBONES_MYSQL_USER,
|
||||
port: process.env.JAMBONES_MYSQL_PORT || 3306,
|
||||
password: process.env.JAMBONES_MYSQL_PASSWORD,
|
||||
database: process.env.JAMBONES_MYSQL_DATABASE,
|
||||
connectionLimit: process.env.JAMBONES_MYSQL_CONNECTION_LIMIT || 10
|
||||
host: JAMBONES_MYSQL_HOST,
|
||||
user: JAMBONES_MYSQL_USER,
|
||||
port: JAMBONES_MYSQL_PORT || 3306,
|
||||
password: JAMBONES_MYSQL_PASSWORD,
|
||||
database: JAMBONES_MYSQL_DATABASE,
|
||||
connectionLimit: JAMBONES_MYSQL_CONNECTION_LIMIT || 10
|
||||
}, logger, tracer);
|
||||
const {
|
||||
client,
|
||||
@@ -138,7 +153,6 @@ function installSrfLocals(srf, logger) {
|
||||
retrieveCall,
|
||||
listCalls,
|
||||
deleteCall,
|
||||
synthAudio,
|
||||
createHash,
|
||||
retrieveHash,
|
||||
deleteKey,
|
||||
@@ -151,19 +165,27 @@ function installSrfLocals(srf, logger) {
|
||||
pushBack,
|
||||
popFront,
|
||||
removeFromList,
|
||||
getListPosition,
|
||||
lengthOfList,
|
||||
getListPosition
|
||||
} = require('@jambonz/realtimedb-helpers')({
|
||||
host: process.env.JAMBONES_REDIS_HOST,
|
||||
port: process.env.JAMBONES_REDIS_PORT || 6379
|
||||
host: JAMBONES_REDIS_HOST,
|
||||
port: JAMBONES_REDIS_PORT || 6379
|
||||
}, logger, tracer);
|
||||
const {
|
||||
synthAudio,
|
||||
getNuanceAccessToken,
|
||||
getIbmAccessToken,
|
||||
} = require('@jambonz/speech-utils')({
|
||||
host: JAMBONES_REDIS_HOST,
|
||||
port: JAMBONES_REDIS_PORT || 6379
|
||||
}, logger, tracer);
|
||||
const {
|
||||
writeAlerts,
|
||||
AlertType
|
||||
} = require('@jambonz/time-series')(logger, {
|
||||
host: process.env.JAMBONES_TIME_SERIES_HOST,
|
||||
host: JAMBONES_TIME_SERIES_HOST,
|
||||
commitSize: 50,
|
||||
commitInterval: 'test' === process.env.NODE_ENV ? 7 : 20
|
||||
commitInterval: 'test' === NODE_ENV ? 7 : 20
|
||||
});
|
||||
|
||||
let localIp;
|
||||
@@ -204,12 +226,14 @@ function installSrfLocals(srf, logger) {
|
||||
popFront,
|
||||
removeFromList,
|
||||
lengthOfList,
|
||||
getListPosition
|
||||
getListPosition,
|
||||
getNuanceAccessToken,
|
||||
getIbmAccessToken
|
||||
},
|
||||
parentLogger: logger,
|
||||
getSBC,
|
||||
getSmpp: () => {
|
||||
return process.env.SMPP_URL;
|
||||
return SMPP_URL;
|
||||
},
|
||||
lifecycleEmitter,
|
||||
getFreeswitch,
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
function normalizeJambones(logger, obj) {
|
||||
if (!Array.isArray(obj)) throw new Error('malformed jambonz payload: must be array');
|
||||
const document = [];
|
||||
for (const tdata of obj) {
|
||||
if (typeof tdata !== 'object') throw new Error('malformed jambonz payload: must be array of objects');
|
||||
if ('verb' in tdata) {
|
||||
// {verb: 'say', text: 'foo..bar'..}
|
||||
const name = tdata.verb;
|
||||
const o = {};
|
||||
Object.keys(tdata)
|
||||
.filter((k) => k !== 'verb')
|
||||
.forEach((k) => o[k] = tdata[k]);
|
||||
const o2 = {};
|
||||
o2[name] = o;
|
||||
document.push(o2);
|
||||
}
|
||||
else if (Object.keys(tdata).length === 1) {
|
||||
// {'say': {..}}
|
||||
document.push(tdata);
|
||||
}
|
||||
else {
|
||||
logger.info(tdata, 'malformed jambonz payload: missing verb property');
|
||||
throw new Error('malformed jambonz payload: missing verb property');
|
||||
}
|
||||
}
|
||||
logger.debug({document}, `normalizeJambones: returning document with ${document.length} tasks`);
|
||||
return document;
|
||||
}
|
||||
|
||||
module.exports = normalizeJambones;
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const makeTask = require('../tasks/make_task');
|
||||
const ConfirmCallSession = require('../session/confirm-call-session');
|
||||
const AdultingCallSession = require('../session/adulting-call-session');
|
||||
@@ -12,7 +12,7 @@ const deepcopy = require('deepcopy');
|
||||
const moment = require('moment');
|
||||
const stripCodecs = require('./strip-ancillary-codecs');
|
||||
const RootSpan = require('./call-tracer');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const uuidv4 = require('uuid-random');
|
||||
|
||||
class SingleDialer extends Emitter {
|
||||
constructor({logger, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan}) {
|
||||
@@ -21,6 +21,7 @@ class SingleDialer extends Emitter {
|
||||
|
||||
this.logger = logger;
|
||||
this.target = target;
|
||||
this.from = target.from || {};
|
||||
this.sbcAddress = sbcAddress;
|
||||
this.opts = opts;
|
||||
this.application = application;
|
||||
@@ -66,8 +67,11 @@ class SingleDialer extends Emitter {
|
||||
opts.headers = {
|
||||
...opts.headers,
|
||||
...(this.target.headers || {}),
|
||||
...(this.from.user && {'X-Preferred-From-User': this.from.user}),
|
||||
...(this.from.host && {'X-Preferred-From-Host': this.from.host}),
|
||||
'X-Jambonz-Routing': this.target.type,
|
||||
'X-Call-Sid': this.callSid
|
||||
'X-Call-Sid': this.callSid,
|
||||
...(this.applicationSid && {'X-Application-Sid': this.applicationSid})
|
||||
};
|
||||
if (srf.locals.fsUUID) {
|
||||
opts.headers = {
|
||||
@@ -408,7 +412,7 @@ class SingleDialer extends Emitter {
|
||||
this.callInfo.updateCallStatus(callStatus, sipStatus, sipReason);
|
||||
if (typeof duration === 'number') this.callInfo.duration = duration;
|
||||
try {
|
||||
this.requestor.request('call:status', this.application.call_status_hook, this.callInfo.toJSON());
|
||||
this.notifier.request('call:status', this.application.call_status_hook, this.callInfo.toJSON());
|
||||
} catch (err) {
|
||||
this.logger.info(err, `SingleDialer:_notifyCallStatusChange error sending ${callStatus} ${sipStatus}`);
|
||||
}
|
||||
|
||||
@@ -1,42 +1,11 @@
|
||||
const bent = require('bent');
|
||||
const parseUrl = require('parse-url');
|
||||
const assert = require('assert');
|
||||
const snakeCaseKeys = require('./snakecase-keys');
|
||||
const crypto = require('crypto');
|
||||
const timeSeries = require('@jambonz/time-series');
|
||||
const {
|
||||
NODE_ENV,
|
||||
JAMBONES_TIME_SERIES_HOST
|
||||
} = require('../config');
|
||||
let alerter ;
|
||||
|
||||
const toBase64 = (str) => Buffer.from(str || '', 'utf8').toString('base64');
|
||||
|
||||
function computeSignature(payload, timestamp, secret) {
|
||||
assert(secret);
|
||||
const data = `${timestamp}.${JSON.stringify(payload)}`;
|
||||
return crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(data, 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function generateSigHeader(payload, secret) {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const signature = computeSignature(payload, timestamp, secret);
|
||||
const scheme = 'v1';
|
||||
return {
|
||||
'Jambonz-Signature': `t=${timestamp},${scheme}=${signature}`
|
||||
};
|
||||
}
|
||||
|
||||
function basicAuth(username, password) {
|
||||
if (!username || !password) return {};
|
||||
const creds = `${username}:${password || ''}`;
|
||||
const header = `Basic ${toBase64(creds)}`;
|
||||
return {Authorization: header};
|
||||
}
|
||||
|
||||
function isRelativeUrl(u) {
|
||||
return typeof u === 'string' && u.startsWith('/');
|
||||
}
|
||||
|
||||
function isAbsoluteUrl(u) {
|
||||
return typeof u === 'string' &&
|
||||
u.startsWith('https://') || u.startsWith('http://');
|
||||
@@ -49,14 +18,6 @@ class Requestor {
|
||||
this.logger = logger;
|
||||
this.url = hook.url;
|
||||
this.method = hook.method || 'POST';
|
||||
this.authHeader = basicAuth(hook.username, hook.password);
|
||||
|
||||
const u = parseUrl(this.url);
|
||||
const myPort = u.port ? `:${u.port}` : '';
|
||||
const baseUrl = this._baseUrl = `${u.protocol}://${u.resource}${myPort}`;
|
||||
|
||||
this.get = bent(baseUrl, 'GET', 'buffer', 200, 201);
|
||||
this.post = bent(baseUrl, 'POST', 'buffer', 200, 201);
|
||||
|
||||
this.username = hook.username;
|
||||
this.password = hook.password;
|
||||
@@ -71,79 +32,22 @@ class Requestor {
|
||||
|
||||
if (!alerter) {
|
||||
alerter = timeSeries(logger, {
|
||||
host: process.env.JAMBONES_TIME_SERIES_HOST,
|
||||
host: JAMBONES_TIME_SERIES_HOST,
|
||||
commitSize: 50,
|
||||
commitInterval: 'test' === process.env.NODE_ENV ? 7 : 20
|
||||
commitInterval: 'test' === NODE_ENV ? 7 : 20
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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(hook, params) {
|
||||
const payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null;
|
||||
const url = hook.url || hook;
|
||||
const method = hook.method || 'POST';
|
||||
|
||||
assert.ok(url, 'Requestor:request url was not provided');
|
||||
assert.ok, (['GET', 'POST'].includes(method), `Requestor:request method must be 'GET' or 'POST' not ${method}`);
|
||||
const {url: urlInfo = hook, method: methodInfo = 'POST'} = hook; // mask user/pass
|
||||
this.logger.debug({url: urlInfo, method: methodInfo, payload}, `Requestor:request ${method} ${url}`);
|
||||
const startAt = process.hrtime();
|
||||
|
||||
let buf;
|
||||
try {
|
||||
const sigHeader = generateSigHeader(payload, this.secret);
|
||||
const headers = {...sigHeader, ...this.authHeader};
|
||||
//this.logger.info({url, headers}, 'send webhook');
|
||||
buf = isRelativeUrl(url) ?
|
||||
await this.post(url, payload, headers) :
|
||||
await bent(method, 'buffer', 200, 201, 202)(url, payload, headers);
|
||||
} catch (err) {
|
||||
this.logger.error({err, secret: this.secret, baseUrl: this.baseUrl, url, statusCode: err.statusCode},
|
||||
`web callback returned unexpected error code ${err.statusCode}`);
|
||||
let opts = {account_sid: this.account_sid};
|
||||
if (err.code === 'ECONNREFUSED') {
|
||||
opts = {...opts, alert_type: alerter.AlertType.WEBHOOK_CONNECTION_FAILURE, url};
|
||||
}
|
||||
else if (err.name === 'StatusError') {
|
||||
opts = {...opts, alert_type: alerter.AlertType.WEBHOOK_STATUS_FAILURE, url, status: err.statusCode};
|
||||
}
|
||||
else {
|
||||
opts = {...opts, alert_type: alerter.AlertType.WEBHOOK_CONNECTION_FAILURE, url, detail: err.message};
|
||||
}
|
||||
alerter.writeAlerts(opts).catch((err) => this.logger.info({err, opts}, 'Error writing alert'));
|
||||
|
||||
throw err;
|
||||
}
|
||||
const diff = process.hrtime(startAt);
|
||||
const time = diff[0] * 1e3 + diff[1] * 1e-6;
|
||||
const rtt = time.toFixed(0);
|
||||
if (buf) this.stats.histogram('app.hook.response_time', rtt, ['hook_type:app']);
|
||||
|
||||
if (buf && buf.toString().length > 0) {
|
||||
try {
|
||||
const json = JSON.parse(buf.toString());
|
||||
this.logger.info({response: json}, `Requestor:request ${method} ${url} succeeded in ${rtt}ms`);
|
||||
return json;
|
||||
}
|
||||
catch (err) {
|
||||
//this.logger.debug({err, url, method}, `Requestor:request returned non-JSON content: '${buf.toString()}'`);
|
||||
}
|
||||
get Alerter() {
|
||||
if (!alerter) {
|
||||
alerter = timeSeries(this.logger, {
|
||||
host: JAMBONES_TIME_SERIES_HOST,
|
||||
commitSize: 50,
|
||||
commitInterval: 'test' === NODE_ENV ? 7 : 20
|
||||
});
|
||||
}
|
||||
return alerter;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,41 @@
|
||||
const assert = require('assert');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const uuidv4 = require('uuid-random');
|
||||
const {LifeCycleEvents, FS_UUID_SET_NAME} = require('./constants');
|
||||
const Emitter = require('events');
|
||||
const debug = require('debug')('jambonz:feature-server');
|
||||
const noopLogger = {info: () => {}, error: () => {}};
|
||||
const {
|
||||
JAMBONES_SBCS,
|
||||
K8S,
|
||||
K8S_SBC_SIP_SERVICE_NAME,
|
||||
AWS_SNS_TOPIC_ARM,
|
||||
OPTIONS_PING_INTERVAL,
|
||||
AWS_REGION,
|
||||
NODE_ENV,
|
||||
JAMBONES_CLUSTER_ID,
|
||||
} = require('../config');
|
||||
|
||||
module.exports = (logger) => {
|
||||
logger = logger || noopLogger;
|
||||
let idxSbc = 0;
|
||||
let sbcs = [];
|
||||
|
||||
if (process.env.JAMBONES_SBCS) {
|
||||
sbcs = process.env.JAMBONES_SBCS
|
||||
if (JAMBONES_SBCS) {
|
||||
sbcs = 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`];
|
||||
else if (K8S && K8S_SBC_SIP_SERVICE_NAME) {
|
||||
sbcs = [`${K8S_SBC_SIP_SERVICE_NAME}:5060`];
|
||||
logger.info({sbcs}, 'SBC inventory');
|
||||
}
|
||||
|
||||
// listen for SNS lifecycle changes
|
||||
let lifecycleEmitter = new Emitter();
|
||||
let dryUpCalls = false;
|
||||
if (process.env.AWS_SNS_TOPIC_ARM && process.env.AWS_REGION) {
|
||||
if (AWS_SNS_TOPIC_ARM && AWS_REGION) {
|
||||
|
||||
(async function() {
|
||||
try {
|
||||
@@ -75,9 +85,13 @@ module.exports = (logger) => {
|
||||
}
|
||||
})();
|
||||
}
|
||||
else if (K8S) {
|
||||
lifecycleEmitter.scaleIn = () => process.exit(0);
|
||||
}
|
||||
|
||||
|
||||
async function pingProxies(srf) {
|
||||
if (process.env.NODE_ENV === 'test') return;
|
||||
if (NODE_ENV === 'test') return;
|
||||
|
||||
for (const sbc of sbcs) {
|
||||
try {
|
||||
@@ -98,14 +112,20 @@ module.exports = (logger) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (process.env.K8S) {
|
||||
if (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}`));
|
||||
|
||||
/* in case redis is restarted, re-insert our key every so often */
|
||||
setInterval(() => {
|
||||
// eslint-disable-next-line max-len
|
||||
addToSet(FS_UUID_SET_NAME, uuid).catch((err) => logger.info({err}, `Error adding ${uuid} to set ${FS_UUID_SET_NAME}`));
|
||||
}, 30000);
|
||||
// eslint-disable-next-line max-len
|
||||
addToSet(FS_UUID_SET_NAME, uuid).catch((err) => logger.info({err}, `Error adding ${uuid} to set ${FS_UUID_SET_NAME}`));
|
||||
});
|
||||
}
|
||||
else {
|
||||
@@ -113,16 +133,16 @@ module.exports = (logger) => {
|
||||
setInterval(() => {
|
||||
const {srf} = require('../..');
|
||||
pingProxies(srf);
|
||||
}, process.env.OPTIONS_PING_INTERVAL || 30000);
|
||||
}, OPTIONS_PING_INTERVAL);
|
||||
|
||||
// initial ping once we are up
|
||||
setTimeout(async() => {
|
||||
|
||||
// if SBCs are auto-scaling, monitor them as they come and go
|
||||
const {srf} = require('../..');
|
||||
if (!process.env.JAMBONES_SBCS) {
|
||||
if (!JAMBONES_SBCS) {
|
||||
const {monitorSet} = srf.locals.dbHelpers;
|
||||
const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-sip`;
|
||||
const setName = `${(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}`);
|
||||
|
||||
257
lib/utils/siprec-utils.js
Normal file
257
lib/utils/siprec-utils.js
Normal file
@@ -0,0 +1,257 @@
|
||||
const xmlParser = require('xml2js').parseString;
|
||||
const uuidv4 = require('uuid-random');
|
||||
const parseUri = require('drachtio-srf').parseUri;
|
||||
const transform = require('sdp-transform');
|
||||
const debug = require('debug')('jambonz:feature-server');
|
||||
|
||||
const parseCallData = (prefix, obj) => {
|
||||
const ret = {};
|
||||
const group = obj[`${prefix}group`];
|
||||
if (group) {
|
||||
const key = Object.keys(group[0]).find((k) => /:?callData$/.test(k));
|
||||
//const o = _.find(group[0], (value, key) => /:?callData$/.test(key));
|
||||
if (key) {
|
||||
//const callData = o[0];
|
||||
const callData = group[0][key];
|
||||
for (const key of Object.keys(callData)) {
|
||||
if (['fromhdr', 'tohdr', 'callid'].includes(key)) ret[key] = callData[key][0];
|
||||
}
|
||||
}
|
||||
}
|
||||
debug('parseCallData', prefix, obj, ret);
|
||||
return ret;
|
||||
};
|
||||
|
||||
/**
|
||||
* parse a SIPREC multiparty body
|
||||
* @param {object} opts - options
|
||||
* @return {Promise}
|
||||
*/
|
||||
const parseSiprecPayload = (req, logger) => {
|
||||
const opts = {};
|
||||
return new Promise((resolve, reject) => {
|
||||
let sdp, meta ;
|
||||
for (let i = 0; i < req.payload.length; i++) {
|
||||
switch (req.payload[i].type) {
|
||||
case 'application/sdp':
|
||||
sdp = req.payload[i].content ;
|
||||
break ;
|
||||
|
||||
case 'application/rs-metadata+xml':
|
||||
case 'application/rs-metadata':
|
||||
meta = opts.xml = req.payload[i].content ;
|
||||
break ;
|
||||
|
||||
default:
|
||||
break ;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!meta && sdp) {
|
||||
const arr = /^([^]+)(m=[^]+?)(m=[^]+?)$/.exec(sdp);
|
||||
opts.sdp1 = `${arr[1]}${arr[2]}`;
|
||||
opts.sdp2 = `${arr[1]}${arr[3]}\r\n`;
|
||||
opts.sessionId = uuidv4();
|
||||
logger.info({ payload: req.payload }, 'SIPREC payload with no metadata (e.g. Cisco NBR)');
|
||||
resolve(opts);
|
||||
} else if (!sdp || !meta) {
|
||||
logger.info({ payload: req.payload }, 'invalid SIPREC payload');
|
||||
return reject(new Error('expected multipart SIPREC body'));
|
||||
}
|
||||
|
||||
xmlParser(meta, (err, result) => {
|
||||
if (err) { throw err; }
|
||||
|
||||
opts.recordingData = result ;
|
||||
opts.sessionId = uuidv4() ;
|
||||
|
||||
const arr = /^([^]+)(m=[^]+?)(m=[^]+?)$/.exec(sdp) ;
|
||||
opts.sdp1 = `${arr[1]}${arr[2]}` ;
|
||||
opts.sdp2 = `${arr[1]}${arr[3]}\r\n` ;
|
||||
|
||||
try {
|
||||
if (typeof result === 'object' && Object.keys(result).length === 1) {
|
||||
const key = Object.keys(result)[0] ;
|
||||
const arr = /^(.*:)recording/.exec(key) ;
|
||||
const prefix = !arr ? '' : (arr[1]) ;
|
||||
const obj = opts.recordingData[`${prefix}recording`];
|
||||
|
||||
// 1. collect participant data
|
||||
const participants = {} ;
|
||||
obj[`${prefix}participant`].forEach((p) => {
|
||||
const partDetails = {} ;
|
||||
participants[p.$.participant_id] = partDetails;
|
||||
if ((`${prefix}nameID` in p) && Array.isArray(p[`${prefix}nameID`])) {
|
||||
partDetails.aor = p[`${prefix}nameID`][0].$.aor;
|
||||
if ('name' in p[`${prefix}nameID`][0] && Array.isArray(p[`${prefix}nameID`][0].name)) {
|
||||
const name = p[`${prefix}nameID`][0].name[0];
|
||||
if (typeof name === 'string') partDetails.name = name ;
|
||||
else if (typeof name === 'object') partDetails.name = name._ ;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. find the associated streams for each participant
|
||||
if (`${prefix}participantstreamassoc` in obj) {
|
||||
obj[`${prefix}participantstreamassoc`].forEach((ps) => {
|
||||
const part = participants[ps.$.participant_id];
|
||||
if (part) {
|
||||
part.send = ps[`${prefix}send`][0];
|
||||
part.recv = ps[`${prefix}recv`][0];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Retrieve stream data
|
||||
opts.caller = {} ;
|
||||
opts.callee = {} ;
|
||||
obj[`${prefix}stream`].forEach((s) => {
|
||||
const streamId = s.$.stream_id;
|
||||
let sender;
|
||||
for (const [k, v] of Object.entries(participants)) {
|
||||
if (v.send === streamId) {
|
||||
sender = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
//const sender = _.find(participants, { 'send': streamId});
|
||||
|
||||
if (!sender) return;
|
||||
|
||||
sender.label = s[`${prefix}label`][0];
|
||||
|
||||
if (-1 !== ['1', 'a_leg', 'inbound'].indexOf(sender.label)) {
|
||||
opts.caller.aor = sender.aor ;
|
||||
if (sender.name) opts.caller.name = sender.name;
|
||||
}
|
||||
else {
|
||||
opts.callee.aor = sender.aor ;
|
||||
if (sender.name) opts.callee.name = sender.name;
|
||||
}
|
||||
});
|
||||
|
||||
// if we dont have a participantstreamassoc then assume the first participant is the caller
|
||||
if (!opts.caller.aor && !opts.callee.aor) {
|
||||
let i = 0;
|
||||
for (const part in participants) {
|
||||
const p = participants[part];
|
||||
if (0 === i && p.aor) {
|
||||
opts.caller.aor = p.aor;
|
||||
opts.caller.name = p.name;
|
||||
}
|
||||
else if (1 === i && p.aor) {
|
||||
opts.callee.aor = p.aor;
|
||||
opts.callee.name = p.name;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// now for Sonus (at least) we get the original from, to and call-id headers in a <callData/> element
|
||||
// if so, this should take preference
|
||||
const callData = parseCallData(prefix, obj);
|
||||
if (callData) {
|
||||
debug(`callData: ${JSON.stringify(callData)}`);
|
||||
opts.originalCallId = callData.callid;
|
||||
|
||||
// caller
|
||||
let r1 = /^(.*)(<sip.*)$/.exec(callData.fromhdr);
|
||||
if (r1) {
|
||||
const arr = /<(.*)>/.exec(r1[2]);
|
||||
if (arr) {
|
||||
const uri = parseUri(arr[1]);
|
||||
const user = uri.user || 'anonymous';
|
||||
opts.caller.aor = `sip:${user}@${uri.host}`;
|
||||
}
|
||||
const dname = r1[1].trim();
|
||||
const arr2 = /"(.*)"/.exec(dname);
|
||||
if (arr2) opts.caller.name = arr2[1];
|
||||
else opts.caller.name = dname;
|
||||
}
|
||||
// callee
|
||||
r1 = /^(.*)(<sip.*)$/.exec(callData.tohdr);
|
||||
if (r1) {
|
||||
const arr = /<(.*)>/.exec(r1[2]);
|
||||
if (arr) {
|
||||
const uri = parseUri(arr[1]);
|
||||
opts.callee.aor = `sip:${uri.user}@${uri.host}`;
|
||||
}
|
||||
const dname = r1[1].trim();
|
||||
const arr2 = /"(.*)"/.exec(dname);
|
||||
if (arr2) opts.callee.name = arr2[1];
|
||||
else opts.callee.name = dname;
|
||||
}
|
||||
debug(`opts.caller from callData: ${JSON.stringify(opts.caller)}`);
|
||||
debug(`opts.callee from callData: ${JSON.stringify(opts.callee)}`);
|
||||
}
|
||||
|
||||
if (opts.caller.aor && 0 !== opts.caller.aor.indexOf('sip:')) {
|
||||
opts.caller.aor = 'sip:' + opts.caller.aor;
|
||||
}
|
||||
if (opts.callee.aor && 0 !== opts.callee.aor.indexOf('sip:')) {
|
||||
opts.callee.aor = 'sip:' + opts.callee.aor;
|
||||
}
|
||||
|
||||
if (opts.caller.aor) {
|
||||
const uri = parseUri(opts.caller.aor);
|
||||
opts.caller.number = uri.user;
|
||||
}
|
||||
if (opts.callee.aor) {
|
||||
const uri = parseUri(opts.callee.aor);
|
||||
opts.callee.number = uri.user;
|
||||
}
|
||||
opts.recordingSessionId = opts.recordingData[`${prefix}recording`][`${prefix}session`][0].$.session_id;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
debug(opts, 'payload parser results');
|
||||
resolve(opts) ;
|
||||
}) ;
|
||||
}) ;
|
||||
};
|
||||
|
||||
const createSipRecPayload = (sdp1, sdp2, logger) => {
|
||||
const sdpObj = [];
|
||||
sdpObj.push(transform.parse(sdp1));
|
||||
sdpObj.push(transform.parse(sdp2));
|
||||
|
||||
//const arr1 = /^([^]+)(c=[^]+)t=[^]+(m=[^]+?)(a=[^]+)$/.exec(sdp1) ;
|
||||
//const arr2 = /^([^]+)(c=[^]+)t=[^]+(m=[^]+?)(a=[^]+)$/.exec(sdp2) ;
|
||||
|
||||
debug(`sdp1: ${sdp1}`);
|
||||
debug(`objSdp[0]: ${JSON.stringify(sdpObj[0])}`);
|
||||
debug(`sdp2: ${sdp2}`);
|
||||
debug(`objSdp[1]: ${JSON.stringify(sdpObj[1])}`);
|
||||
|
||||
if (!sdpObj[0] || !sdpObj[0].media.length) {
|
||||
throw new Error(`Error parsing sdp1 into component parts: ${sdp1}`);
|
||||
}
|
||||
else if (!sdpObj[1] || !sdpObj[1].media.length) {
|
||||
throw new Error(`Error parsing sdp2 into component parts: ${sdp2}`);
|
||||
}
|
||||
|
||||
if (!sdpObj[0].media[0].label) sdpObj[0].media[0].label = 1;
|
||||
if (!sdpObj[1].media[0].label) sdpObj[1].media[0].label = 2;
|
||||
|
||||
//const aLabel = sdp1.includes('a=label:') ? '' : 'a=label:1\r\n';
|
||||
//const bLabel = sdp2.includes('a=label:') ? '' : 'a=label:2\r\n';
|
||||
|
||||
sdpObj[0].media = sdpObj[0].media.concat(sdpObj[1].media);
|
||||
const combinedSdp = transform.write(sdpObj[0])
|
||||
.replace(/a=sendonly\r\n/g, '')
|
||||
.replace(/a=direction:both\r\n/g, '');
|
||||
|
||||
debug(`combined ${combinedSdp}`);
|
||||
/*
|
||||
const combinedSdp = `${arr1[1]}t=0 0\r\n${arr1[2]}${arr1[3]}${arr1[4]}${aLabel}${arr2[3]}${arr2[4]}${bLabel}`
|
||||
.replace(/a=sendonly\r\n/g, '')
|
||||
.replace(/a=direction:both\r\n/g, '');
|
||||
*/
|
||||
|
||||
return combinedSdp.replace(/sendrecv/g, 'recvonly');
|
||||
};
|
||||
|
||||
module.exports = { parseSiprecPayload, createSipRecPayload } ;
|
||||
679
lib/utils/transcription-utils.js
Normal file
679
lib/utils/transcription-utils.js
Normal file
@@ -0,0 +1,679 @@
|
||||
const {
|
||||
TaskName,
|
||||
AzureTranscriptionEvents,
|
||||
GoogleTranscriptionEvents,
|
||||
AwsTranscriptionEvents,
|
||||
NuanceTranscriptionEvents,
|
||||
DeepgramTranscriptionEvents,
|
||||
SonioxTranscriptionEvents,
|
||||
NvidiaTranscriptionEvents,
|
||||
JambonzTranscriptionEvents
|
||||
} = require('./constants');
|
||||
|
||||
const stickyVars = {
|
||||
google: [
|
||||
'GOOGLE_SPEECH_HINTS',
|
||||
'GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL',
|
||||
'GOOGLE_SPEECH_PROFANITY_FILTER',
|
||||
'GOOGLE_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION',
|
||||
'GOOGLE_SPEECH_ENABLE_WORD_TIME_OFFSETS',
|
||||
'GOOGLE_SPEECH_SINGLE_UTTERANCE',
|
||||
'GOOGLE_SPEECH_SPEAKER_DIARIZATION',
|
||||
'GOOGLE_SPEECH_USE_ENHANCED',
|
||||
'GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES',
|
||||
'GOOGLE_SPEECH_METADATA_INTERACTION_TYPE',
|
||||
'GOOGLE_SPEECH_METADATA_INDUSTRY_NAICS_CODE'
|
||||
],
|
||||
microsoft: [
|
||||
'AZURE_SPEECH_HINTS',
|
||||
'AZURE_SERVICE_ENDPOINT_ID',
|
||||
'AZURE_REQUEST_SNR',
|
||||
'AZURE_PROFANITY_OPTION',
|
||||
'AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES',
|
||||
'AZURE_SERVICE_ENDPOINT',
|
||||
'AZURE_INITIAL_SPEECH_TIMEOUT_MS',
|
||||
'AZURE_USE_OUTPUT_FORMAT_DETAILED',
|
||||
],
|
||||
deepgram: [
|
||||
'DEEPGRAM_SPEECH_KEYWORDS',
|
||||
'DEEPGRAM_API_KEY',
|
||||
'DEEPGRAM_SPEECH_TIER',
|
||||
'DEEPGRAM_SPEECH_MODEL',
|
||||
'DEEPGRAM_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION',
|
||||
'DEEPGRAM_SPEECH_PROFANITY_FILTER',
|
||||
'DEEPGRAM_SPEECH_REDACT',
|
||||
'DEEPGRAM_SPEECH_DIARIZE',
|
||||
'DEEPGRAM_SPEECH_NER',
|
||||
'DEEPGRAM_SPEECH_ALTERNATIVES',
|
||||
'DEEPGRAM_SPEECH_NUMERALS',
|
||||
'DEEPGRAM_SPEECH_SEARCH',
|
||||
'DEEPGRAM_SPEECH_REPLACE',
|
||||
'DEEPGRAM_SPEECH_ENDPOINTING',
|
||||
'DEEPGRAM_SPEECH_VAD_TURNOFF',
|
||||
'DEEPGRAM_SPEECH_TAG'
|
||||
],
|
||||
aws: [
|
||||
'AWS_VOCABULARY_NAME',
|
||||
'AWS_VOCABULARY_FILTER_METHOD',
|
||||
'AWS_VOCABULARY_FILTER_NAME'
|
||||
],
|
||||
nuance: [
|
||||
'NUANCE_ACCESS_TOKEN',
|
||||
'NUANCE_KRYPTON_ENDPOINT',
|
||||
'NUANCE_TOPIC',
|
||||
'NUANCE_UTTERANCE_DETECTION_MODE',
|
||||
'NUANCE_FILTER_PROFANITY',
|
||||
'NUANCE_INCLUDE_TOKENIZATION',
|
||||
'NUANCE_DISCARD_SPEAKER_ADAPTATION',
|
||||
'NUANCE_SUPPRESS_CALL_RECORDING',
|
||||
'NUANCE_MASK_LOAD_FAILURES',
|
||||
'NUANCE_SUPPRESS_INITIAL_CAPITALIZATION',
|
||||
'NUANCE_ALLOW_ZERO_BASE_LM_WEIGHT',
|
||||
'NUANCE_FILTER_WAKEUP_WORD',
|
||||
'NUANCE_NO_INPUT_TIMEOUT_MS',
|
||||
'NUANCE_RECOGNITION_TIMEOUT_MS',
|
||||
'NUANCE_UTTERANCE_END_SILENCE_MS',
|
||||
'NUANCE_MAX_HYPOTHESES',
|
||||
'NUANCE_SPEECH_DOMAIN',
|
||||
'NUANCE_FORMATTING',
|
||||
'NUANCE_RESOURCES'
|
||||
],
|
||||
ibm: [
|
||||
'IBM_ACCESS_TOKEN',
|
||||
'IBM_SPEECH_REGION',
|
||||
'IBM_SPEECH_INSTANCE_ID',
|
||||
'IBM_SPEECH_MODEL',
|
||||
'IBM_SPEECH_LANGUAGE_CUSTOMIZATION_ID',
|
||||
'IBM_SPEECH_ACOUSTIC_CUSTOMIZATION_ID',
|
||||
'IBM_SPEECH_BASE_MODEL_VERSION',
|
||||
'IBM_SPEECH_WATSON_METADATA',
|
||||
'IBM_SPEECH_WATSON_LEARNING_OPT_OUT'
|
||||
],
|
||||
nvidia: [
|
||||
'NVIDIA_HINTS'
|
||||
],
|
||||
soniox: [
|
||||
'SONIOX_PROFANITY_FILTER',
|
||||
'SONIOX_MODEL'
|
||||
]
|
||||
};
|
||||
|
||||
const compileSonioxTranscripts = (finalWordChunks, channel, language) => {
|
||||
const words = finalWordChunks.flat();
|
||||
const transcript = words.reduce((acc, word) => {
|
||||
if (word.text === '<end>') return acc;
|
||||
if ([',', '.', '?', '!'].includes(word.text)) return `${acc}${word.text}`;
|
||||
return `${acc} ${word.text}`;
|
||||
}, '').trim();
|
||||
const realWords = words.filter((word) => ![',.!?;'].includes(word.text) && word.text !== '<end>');
|
||||
const confidence = realWords.reduce((acc, word) => acc + word.confidence, 0) / realWords.length;
|
||||
const alternatives = [{transcript, confidence}];
|
||||
return {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final: true,
|
||||
alternatives,
|
||||
vendor: {
|
||||
name: 'soniox',
|
||||
evt: words
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeSoniox = (evt, channel, language) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
|
||||
/* an <end> token indicates the end of an utterance */
|
||||
const endTokenPos = evt.words.map((w) => w.text).indexOf('<end>');
|
||||
const endpointReached = endTokenPos !== -1;
|
||||
const words = endpointReached ? evt.words.slice(0, endTokenPos) : evt.words;
|
||||
|
||||
/* note: we can safely ignore words after the <end> token as they will be returned again */
|
||||
const finalWords = words.filter((word) => word.is_final);
|
||||
const nonFinalWords = words.filter((word) => !word.is_final);
|
||||
|
||||
const is_final = endpointReached && finalWords.length > 0;
|
||||
const transcript = words.reduce((acc, word) => {
|
||||
if ([',', '.', '?', '!'].includes(word.text)) return `${acc}${word.text}`;
|
||||
else return `${acc} ${word.text}`;
|
||||
}, '').trim();
|
||||
const realWords = words.filter((word) => ![',.!?;'].includes(word.text) && word.text !== '<end>');
|
||||
const confidence = realWords.reduce((acc, word) => acc + word.confidence, 0) / realWords.length;
|
||||
const alternatives = [{transcript, confidence}];
|
||||
return {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final,
|
||||
alternatives,
|
||||
vendor: {
|
||||
name: 'soniox',
|
||||
endpointReached,
|
||||
evt: copy,
|
||||
finalWords,
|
||||
nonFinalWords
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeDeepgram = (evt, channel, language) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
const alternatives = (evt.channel?.alternatives || [])
|
||||
.map((alt) => ({
|
||||
confidence: alt.confidence,
|
||||
transcript: alt.transcript,
|
||||
}));
|
||||
|
||||
return {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final: evt.is_final,
|
||||
alternatives: [alternatives[0]],
|
||||
vendor: {
|
||||
name: 'deepgram',
|
||||
evt: copy
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeNvidia = (evt, channel, language) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
const alternatives = (evt.alternatives || [])
|
||||
.map((alt) => ({
|
||||
confidence: alt.confidence,
|
||||
transcript: alt.transcript,
|
||||
}));
|
||||
return {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final: evt.is_final,
|
||||
alternatives,
|
||||
vendor: {
|
||||
name: 'nvidia',
|
||||
evt: copy
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeIbm = (evt, channel, language) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
//const idx = evt.result_index;
|
||||
const result = evt.results[0];
|
||||
|
||||
return {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final: result.final,
|
||||
alternatives: result.alternatives,
|
||||
vendor: {
|
||||
name: 'ibm',
|
||||
evt: copy
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeGoogle = (evt, channel, language) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
return {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final: evt.is_final,
|
||||
alternatives: [evt.alternatives[0]],
|
||||
vendor: {
|
||||
name: 'google',
|
||||
evt: copy
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeCustom = (evt, channel, language) => {
|
||||
return {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final: evt.is_final,
|
||||
alternatives: [evt.alternatives[0]]
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeNuance = (evt, channel, language) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
return {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final: evt.is_final,
|
||||
alternatives: [evt.alternatives[0]],
|
||||
vendor: {
|
||||
name: 'nuance',
|
||||
evt: copy
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeMicrosoft = (evt, channel, language) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
const nbest = evt.NBest;
|
||||
const language_code = evt.PrimaryLanguage?.Language || language;
|
||||
const alternatives = nbest ? nbest.map((n) => {
|
||||
return {
|
||||
confidence: n.Confidence,
|
||||
transcript: n.Display
|
||||
};
|
||||
}) :
|
||||
[
|
||||
{
|
||||
transcript: evt.DisplayText || evt.Text
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
language_code,
|
||||
channel_tag: channel,
|
||||
is_final: evt.RecognitionStatus === 'Success',
|
||||
alternatives: [alternatives[0]],
|
||||
vendor: {
|
||||
name: 'microsoft',
|
||||
evt: copy
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAws = (evt, channel, language) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
return {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final: evt[0].is_final,
|
||||
alternatives: evt[0].alternatives,
|
||||
vendor: {
|
||||
name: 'aws',
|
||||
evt: copy
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
module.exports = (logger) => {
|
||||
const normalizeTranscription = (evt, vendor, channel, language) => {
|
||||
|
||||
//logger.debug({ evt, vendor, channel, language }, 'normalizeTranscription');
|
||||
switch (vendor) {
|
||||
case 'deepgram':
|
||||
return normalizeDeepgram(evt, channel, language);
|
||||
case 'microsoft':
|
||||
return normalizeMicrosoft(evt, channel, language);
|
||||
case 'google':
|
||||
return normalizeGoogle(evt, channel, language);
|
||||
case 'aws':
|
||||
return normalizeAws(evt, channel, language);
|
||||
case 'nuance':
|
||||
return normalizeNuance(evt, channel, language);
|
||||
case 'ibm':
|
||||
return normalizeIbm(evt, channel, language);
|
||||
case 'nvidia':
|
||||
return normalizeNvidia(evt, channel, language);
|
||||
case 'soniox':
|
||||
return normalizeSoniox(evt, channel, language);
|
||||
default:
|
||||
if (vendor.startsWith('custom:')) {
|
||||
return normalizeCustom(evt, channel, language);
|
||||
}
|
||||
logger.error(`Unknown vendor ${vendor}`);
|
||||
return evt;
|
||||
}
|
||||
};
|
||||
|
||||
const setChannelVarsForStt = (task, sttCredentials, rOpts = {}) => {
|
||||
let opts = {};
|
||||
const {enable, voiceMs = 0, mode = -1} = rOpts.vad || {};
|
||||
const vad = {enable, voiceMs, mode};
|
||||
const vendor = rOpts.vendor;
|
||||
|
||||
/* voice activity detection works across vendors */
|
||||
opts = {
|
||||
...opts,
|
||||
...(vad.enable && {START_RECOGNIZING_ON_VAD: 1}),
|
||||
...(vad.enable && vad.voiceMs && {RECOGNIZER_VAD_VOICE_MS: vad.voiceMs}),
|
||||
...(vad.enable && typeof vad.mode === 'number' && {RECOGNIZER_VAD_MODE: vad.mode}),
|
||||
};
|
||||
|
||||
if ('google' === vendor) {
|
||||
const model = task.name === TaskName.Gather ? 'command_and_search' : 'latest_long';
|
||||
opts = {
|
||||
...opts,
|
||||
...(sttCredentials && {GOOGLE_APPLICATION_CREDENTIALS: JSON.stringify(sttCredentials.credentials)}),
|
||||
...(rOpts.separateRecognitionPerChannel && {GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL: 1}),
|
||||
...(rOpts.separateRecognitionPerChanne === false && {GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL: 0}),
|
||||
...(rOpts.profanityFilter && {GOOGLE_SPEECH_PROFANITY_FILTER: 1}),
|
||||
...(rOpts.punctuation && {GOOGLE_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION: 1}),
|
||||
...(rOpts.words && {GOOGLE_SPEECH_ENABLE_WORD_TIME_OFFSETS: 1}),
|
||||
...(rOpts.singleUtterance && {GOOGLE_SPEECH_SINGLE_UTTERANCE: 1}),
|
||||
...(rOpts.diarization && {GOOGLE_SPEECH_SPEAKER_DIARIZATION: 1}),
|
||||
...(rOpts.diarization && rOpts.diarizationMinSpeakers > 0 &&
|
||||
{GOOGLE_SPEECH_SPEAKER_DIARIZATION_MIN_SPEAKER_COUNT: rOpts.diarizationMinSpeakers}),
|
||||
...(rOpts.diarization && rOpts.diarizationMaxSpeakers > 0 &&
|
||||
{GOOGLE_SPEECH_SPEAKER_DIARIZATION_MAX_SPEAKER_COUNT: rOpts.diarizationMaxSpeakers}),
|
||||
...(rOpts.enhancedModel !== false && {GOOGLE_SPEECH_USE_ENHANCED: 1}),
|
||||
...(rOpts.profanityFilter === false && {GOOGLE_SPEECH_PROFANITY_FILTER: 0}),
|
||||
...(rOpts.punctuation === false && {GOOGLE_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION: 0}),
|
||||
...(rOpts.words == false && {GOOGLE_SPEECH_ENABLE_WORD_TIME_OFFSETS: 0}),
|
||||
...(rOpts.diarization === false && {GOOGLE_SPEECH_SPEAKER_DIARIZATION: 0}),
|
||||
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'string' &&
|
||||
{GOOGLE_SPEECH_HINTS: rOpts.hints.join(',')}),
|
||||
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'object' &&
|
||||
{GOOGLE_SPEECH_HINTS: JSON.stringify(rOpts.hints)}),
|
||||
...(typeof rOpts.hintsBoost === 'number' && {GOOGLE_SPEECH_HINTS_BOOST: rOpts.hintsBoost}),
|
||||
...(rOpts.altLanguages.length > 0 &&
|
||||
{GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES: [...new Set(rOpts.altLanguages)].join(',')}),
|
||||
...(rOpts.interactionType &&
|
||||
{GOOGLE_SPEECH_METADATA_INTERACTION_TYPE: rOpts.interactionType}),
|
||||
...{GOOGLE_SPEECH_MODEL: rOpts.model || model},
|
||||
...(rOpts.naicsCode > 0 && {GOOGLE_SPEECH_METADATA_INDUSTRY_NAICS_CODE: rOpts.naicsCode}),
|
||||
GOOGLE_SPEECH_METADATA_RECORDING_DEVICE_TYPE: 'phone_line',
|
||||
};
|
||||
}
|
||||
else if (['aws', 'polly'].includes(vendor)) {
|
||||
opts = {
|
||||
...opts,
|
||||
...(rOpts.vocabularyName && {AWS_VOCABULARY_NAME: rOpts.vocabularyName}),
|
||||
...(rOpts.vocabularyFilterName && {AWS_VOCABULARY_FILTER_NAME: rOpts.vocabularyFilterName}),
|
||||
...(rOpts.filterMethod && {AWS_VOCABULARY_FILTER_METHOD: rOpts.filterMethod}),
|
||||
...(sttCredentials && {
|
||||
AWS_ACCESS_KEY_ID: sttCredentials.accessKeyId,
|
||||
AWS_SECRET_ACCESS_KEY: sttCredentials.secretAccessKey,
|
||||
AWS_REGION: sttCredentials.region
|
||||
}),
|
||||
};
|
||||
}
|
||||
else if ('microsoft' === vendor) {
|
||||
opts = {
|
||||
...opts,
|
||||
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'string' &&
|
||||
{AZURE_SPEECH_HINTS: rOpts.hints.map((h) => h.trim()).join(',')}),
|
||||
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'object' &&
|
||||
{AZURE_SPEECH_HINTS: rOpts.hints.map((h) => h.phrase).join(',')}),
|
||||
...(rOpts.altLanguages && rOpts.altLanguages.length > 0 &&
|
||||
{AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES: [...new Set(rOpts.altLanguages)].join(',')}),
|
||||
...(rOpts.requestSnr && {AZURE_REQUEST_SNR: 1}),
|
||||
...(rOpts.profanityOption && {AZURE_PROFANITY_OPTION: rOpts.profanityOption}),
|
||||
...(rOpts.azureServiceEndpoint && {AZURE_SERVICE_ENDPOINT: rOpts.azureServiceEndpoint}),
|
||||
...(rOpts.initialSpeechTimeoutMs > 0 &&
|
||||
{AZURE_INITIAL_SPEECH_TIMEOUT_MS: rOpts.initialSpeechTimeoutMs}),
|
||||
...(rOpts.requestSnr && {AZURE_REQUEST_SNR: 1}),
|
||||
...(rOpts.audioLogging && {AZURE_AUDIO_LOGGING: 1}),
|
||||
...{AZURE_USE_OUTPUT_FORMAT_DETAILED: 1},
|
||||
...(sttCredentials && {
|
||||
AZURE_SUBSCRIPTION_KEY: sttCredentials.api_key,
|
||||
AZURE_REGION: sttCredentials.region,
|
||||
}),
|
||||
...(sttCredentials.use_custom_stt && sttCredentials.custom_stt_endpoint &&
|
||||
{AZURE_SERVICE_ENDPOINT_ID: sttCredentials.custom_stt_endpoint})
|
||||
};
|
||||
}
|
||||
else if ('nuance' === vendor) {
|
||||
/**
|
||||
* Note: all nuance options are in recognizer.nuanceOptions, should migrate
|
||||
* other vendor settings to similar nested structure
|
||||
*/
|
||||
const {nuanceOptions = {}} = rOpts;
|
||||
opts = {
|
||||
...opts,
|
||||
...(sttCredentials.access_token) && {NUANCE_ACCESS_TOKEN: sttCredentials.access_token},
|
||||
...(sttCredentials.nuance_stt_uri) && {NUANCE_KRYPTON_ENDPOINT: sttCredentials.nuance_stt_uri},
|
||||
...(nuanceOptions.topic) && {NUANCE_TOPIC: nuanceOptions.topic},
|
||||
...(nuanceOptions.utteranceDetectionMode) &&
|
||||
{NUANCE_UTTERANCE_DETECTION_MODE: nuanceOptions.utteranceDetectionMode},
|
||||
...(nuanceOptions.punctuation || rOpts.punctuation) && {NUANCE_PUNCTUATION: nuanceOptions.punctuation},
|
||||
...(nuanceOptions.profanityFilter) &&
|
||||
{NUANCE_FILTER_PROFANITY: nuanceOptions.profanityFilter},
|
||||
...(nuanceOptions.includeTokenization) &&
|
||||
{NUANCE_INCLUDE_TOKENIZATION: nuanceOptions.includeTokenization},
|
||||
...(nuanceOptions.discardSpeakerAdaptation) &&
|
||||
{NUANCE_DISCARD_SPEAKER_ADAPTATION: nuanceOptions.discardSpeakerAdaptation},
|
||||
...(nuanceOptions.suppressCallRecording) &&
|
||||
{NUANCE_SUPPRESS_CALL_RECORDING: nuanceOptions.suppressCallRecording},
|
||||
...(nuanceOptions.maskLoadFailures) &&
|
||||
{NUANCE_MASK_LOAD_FAILURES: nuanceOptions.maskLoadFailures},
|
||||
...(nuanceOptions.suppressInitialCapitalization) &&
|
||||
{NUANCE_SUPPRESS_INITIAL_CAPITALIZATION: nuanceOptions.suppressInitialCapitalization},
|
||||
...(nuanceOptions.allowZeroBaseLmWeight)
|
||||
&& {NUANCE_ALLOW_ZERO_BASE_LM_WEIGHT: nuanceOptions.allowZeroBaseLmWeight},
|
||||
...(nuanceOptions.filterWakeupWord) &&
|
||||
{NUANCE_FILTER_WAKEUP_WORD: nuanceOptions.filterWakeupWord},
|
||||
...(nuanceOptions.resultType) &&
|
||||
{NUANCE_RESULT_TYPE: nuanceOptions.resultType || rOpts.interim ? 'partial' : 'final'},
|
||||
...(nuanceOptions.noInputTimeoutMs) &&
|
||||
{NUANCE_NO_INPUT_TIMEOUT_MS: nuanceOptions.noInputTimeoutMs},
|
||||
...(nuanceOptions.recognitionTimeoutMs) &&
|
||||
{NUANCE_RECOGNITION_TIMEOUT_MS: nuanceOptions.recognitionTimeoutMs},
|
||||
...(nuanceOptions.utteranceEndSilenceMs) &&
|
||||
{NUANCE_UTTERANCE_END_SILENCE_MS: nuanceOptions.utteranceEndSilenceMs},
|
||||
...(nuanceOptions.maxHypotheses) &&
|
||||
{NUANCE_MAX_HYPOTHESES: nuanceOptions.maxHypotheses},
|
||||
...(nuanceOptions.speechDomain) &&
|
||||
{NUANCE_SPEECH_DOMAIN: nuanceOptions.speechDomain},
|
||||
...(nuanceOptions.formatting) &&
|
||||
{NUANCE_FORMATTING: nuanceOptions.formatting},
|
||||
...(nuanceOptions.resources) &&
|
||||
{NUANCE_RESOURCES: JSON.stringify(nuanceOptions.resources)},
|
||||
};
|
||||
}
|
||||
else if ('deepgram' === vendor) {
|
||||
const {deepgramOptions = {}} = rOpts;
|
||||
opts = {
|
||||
...opts,
|
||||
...(sttCredentials.api_key) &&
|
||||
{DEEPGRAM_API_KEY: sttCredentials.api_key},
|
||||
...(deepgramOptions.tier) &&
|
||||
{DEEPGRAM_SPEECH_TIER: deepgramOptions.tier},
|
||||
...(deepgramOptions.model) &&
|
||||
{DEEPGRAM_SPEECH_MODEL: deepgramOptions.model},
|
||||
...(deepgramOptions.punctuate) &&
|
||||
{DEEPGRAM_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION: 1},
|
||||
...(deepgramOptions.profanityFilter) &&
|
||||
{DEEPGRAM_SPEECH_PROFANITY_FILTER: 1},
|
||||
...(deepgramOptions.redact) &&
|
||||
{DEEPGRAM_SPEECH_REDACT: 1},
|
||||
...(deepgramOptions.diarize) &&
|
||||
{DEEPGRAM_SPEECH_DIARIZE: 1},
|
||||
...(deepgramOptions.diarizeVersion) &&
|
||||
{DEEPGRAM_SPEECH_DIARIZE_VERSION: deepgramOptions.diarizeVersion},
|
||||
...(deepgramOptions.ner) &&
|
||||
{DEEPGRAM_SPEECH_NER: 1},
|
||||
...(deepgramOptions.alternatives) &&
|
||||
{DEEPGRAM_SPEECH_ALTERNATIVES: deepgramOptions.alternatives},
|
||||
...(deepgramOptions.numerals) &&
|
||||
{DEEPGRAM_SPEECH_NUMERALS: deepgramOptions.numerals},
|
||||
...(deepgramOptions.search) &&
|
||||
{DEEPGRAM_SPEECH_SEARCH: deepgramOptions.search.join(',')},
|
||||
...(deepgramOptions.replace) &&
|
||||
{DEEPGRAM_SPEECH_REPLACE: deepgramOptions.replace.join(',')},
|
||||
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'string' &&
|
||||
{DEEPGRAM_SPEECH_KEYWORDS: rOpts.hints.map((h) => h.trim()).join(',')}),
|
||||
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'object' &&
|
||||
{DEEPGRAM_SPEECH_KEYWORDS: rOpts.hints.map((h) => h.phrase).join(',')}),
|
||||
...(deepgramOptions.keywords) &&
|
||||
{DEEPGRAM_SPEECH_KEYWORDS: deepgramOptions.keywords.join(',')},
|
||||
...('endpointing' in deepgramOptions) &&
|
||||
{DEEPGRAM_SPEECH_ENDPOINTING: deepgramOptions.endpointing},
|
||||
...(deepgramOptions.vadTurnoff) &&
|
||||
{DEEPGRAM_SPEECH_VAD_TURNOFF: deepgramOptions.vadTurnoff},
|
||||
...(deepgramOptions.tag) &&
|
||||
{DEEPGRAM_SPEECH_TAG: deepgramOptions.tag}
|
||||
};
|
||||
}
|
||||
else if ('soniox' === vendor) {
|
||||
const {sonioxOptions = {}} = rOpts;
|
||||
const {storage = {}} = sonioxOptions;
|
||||
opts = {
|
||||
...opts,
|
||||
...(sttCredentials.api_key) &&
|
||||
{SONIOX_API_KEY: sttCredentials.api_key},
|
||||
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'string' &&
|
||||
{SONIOX_HINTS: rOpts.hints.join(',')}),
|
||||
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'object' &&
|
||||
{SONIOX_HINTS: JSON.stringify(rOpts.hints)}),
|
||||
...(typeof rOpts.hintsBoost === 'number' &&
|
||||
{SONIOX_HINTS_BOOST: rOpts.hintsBoost}),
|
||||
...(sonioxOptions.model) &&
|
||||
{SONIOX_MODEL: sonioxOptions.model},
|
||||
...((sonioxOptions.profanityFilter || rOpts.profanityFilter) && {SONIOX_PROFANITY_FILTER: 1}),
|
||||
...(storage?.id && {SONIOX_STORAGE_ID: storage.id}),
|
||||
...(storage?.id && storage?.title && {SONIOX_STORAGE_TITLE: storage.title}),
|
||||
...(storage?.id && storage?.disableStoreAudio && {SONIOX_STORAGE_DISABLE_AUDIO: 1}),
|
||||
...(storage?.id && storage?.disableStoreTranscript && {SONIOX_STORAGE_DISABLE_TRANSCRIPT: 1}),
|
||||
...(storage?.id && storage?.disableSearch && {SONIOX_STORAGE_DISABLE_SEARCH: 1})
|
||||
};
|
||||
}
|
||||
else if ('ibm' === vendor) {
|
||||
const {ibmOptions = {}} = rOpts;
|
||||
opts = {
|
||||
...opts,
|
||||
...(sttCredentials.access_token) &&
|
||||
{IBM_ACCESS_TOKEN: sttCredentials.access_token},
|
||||
...(sttCredentials.stt_region) &&
|
||||
{IBM_SPEECH_REGION: sttCredentials.stt_region},
|
||||
...(sttCredentials.instance_id) &&
|
||||
{IBM_SPEECH_INSTANCE_ID: sttCredentials.instance_id},
|
||||
...(ibmOptions.model) &&
|
||||
{IBM_SPEECH_MODEL: ibmOptions.model},
|
||||
...(ibmOptions.language_customization_id) &&
|
||||
{IBM_SPEECH_LANGUAGE_CUSTOMIZATION_ID: ibmOptions.language_customization_id},
|
||||
...(ibmOptions.acoustic_customization_id) &&
|
||||
{IBM_SPEECH_ACOUSTIC_CUSTOMIZATION_ID: ibmOptions.acoustic_customization_id},
|
||||
...(ibmOptions.baseModelVersion) &&
|
||||
{IBM_SPEECH_BASE_MODEL_VERSION: ibmOptions.baseModelVersion},
|
||||
...(ibmOptions.watsonMetadata) &&
|
||||
{IBM_SPEECH_WATSON_METADATA: ibmOptions.watsonMetadata},
|
||||
...(ibmOptions.watsonLearningOptOut) &&
|
||||
{IBM_SPEECH_WATSON_LEARNING_OPT_OUT: ibmOptions.watsonLearningOptOut}
|
||||
};
|
||||
}
|
||||
else if ('nvidia' === vendor) {
|
||||
const {nvidiaOptions = {}} = rOpts;
|
||||
const rivaUri = nvidiaOptions.rivaUri || sttCredentials.riva_server_uri;
|
||||
opts = {
|
||||
...opts,
|
||||
...((nvidiaOptions.profanityFilter || rOpts.profanityFilter) && {NVIDIA_PROFANITY_FILTER: 1}),
|
||||
...(!(nvidiaOptions.profanityFilter || rOpts.profanityFilter) && {NVIDIA_PROFANITY_FILTER: 0}),
|
||||
...((nvidiaOptions.punctuation || rOpts.punctuation) && {NVIDIA_PUNCTUATION: 1}),
|
||||
...(!(nvidiaOptions.punctuation || rOpts.punctuation) && {NVIDIA_PUNCTUATION: 0}),
|
||||
...((rOpts.words || nvidiaOptions.wordTimeOffsets) && {NVIDIA_WORD_TIME_OFFSETS: 1}),
|
||||
...(!(rOpts.words || nvidiaOptions.wordTimeOffsets) && {NVIDIA_WORD_TIME_OFFSETS: 0}),
|
||||
...(nvidiaOptions.maxAlternatives && {NVIDIA_MAX_ALTERNATIVES: nvidiaOptions.maxAlternatives}),
|
||||
...(!nvidiaOptions.maxAlternatives && {NVIDIA_MAX_ALTERNATIVES: 1}),
|
||||
...(rOpts.model && {NVIDIA_MODEL: rOpts.model}),
|
||||
...(rivaUri && {NVIDIA_RIVA_URI: rivaUri}),
|
||||
...(nvidiaOptions.verbatimTranscripts && {NVIDIA_VERBATIM_TRANSCRIPTS: 1}),
|
||||
...(rOpts.diarization && {NVIDIA_SPEAKER_DIARIZATION: 1}),
|
||||
...(rOpts.diarization && rOpts.diarizationMaxSpeakers > 0 &&
|
||||
{NVIDIA_DIARIZATION_SPEAKER_COUNT: rOpts.diarizationMaxSpeakers}),
|
||||
...(rOpts.separateRecognitionPerChannel && {NVIDIA_SEPARATE_RECOGNITION_PER_CHANNEL: 1}),
|
||||
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'string' &&
|
||||
{NVIDIA_HINTS: rOpts.hints.join(',')}),
|
||||
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'object' &&
|
||||
{NVIDIA_HINTS: JSON.stringify(rOpts.hints)}),
|
||||
...(typeof rOpts.hintsBoost === 'number' &&
|
||||
{NVIDIA_HINTS_BOOST: rOpts.hintsBoost}),
|
||||
...(nvidiaOptions.customConfiguration &&
|
||||
{NVIDIA_CUSTOM_CONFIGURATION: JSON.stringify(nvidiaOptions.customConfiguration)}),
|
||||
};
|
||||
}
|
||||
else if (vendor.startsWith('custom:')) {
|
||||
let {options = {}} = rOpts;
|
||||
const {auth_token, custom_stt_url} = sttCredentials;
|
||||
options = {
|
||||
...options,
|
||||
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'string' &&
|
||||
{hints: rOpts.hints}),
|
||||
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'object' &&
|
||||
{hints: JSON.stringify(rOpts.hints)}),
|
||||
...(typeof rOpts.hintsBoost === 'number' && {hintsBoost: rOpts.hintsBoost})
|
||||
};
|
||||
|
||||
opts = {
|
||||
...opts,
|
||||
JAMBONZ_STT_API_KEY: auth_token,
|
||||
JAMBONZ_STT_URL: custom_stt_url,
|
||||
...(Object.keys(options).length > 0 && {JAMBONZ_STT_OPTIONS: JSON.stringify(options)}),
|
||||
};
|
||||
}
|
||||
|
||||
(stickyVars[vendor] || []).forEach((key) => {
|
||||
if (!opts[key]) opts[key] = '';
|
||||
});
|
||||
return opts;
|
||||
};
|
||||
|
||||
const removeSpeechListeners = (ep) => {
|
||||
ep.removeCustomEventListener(GoogleTranscriptionEvents.Transcription);
|
||||
ep.removeCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance);
|
||||
ep.removeCustomEventListener(GoogleTranscriptionEvents.VadDetected);
|
||||
|
||||
ep.removeCustomEventListener(AwsTranscriptionEvents.Transcription);
|
||||
ep.removeCustomEventListener(AwsTranscriptionEvents.VadDetected);
|
||||
|
||||
ep.removeCustomEventListener(AzureTranscriptionEvents.Transcription);
|
||||
ep.removeCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected);
|
||||
ep.removeCustomEventListener(AzureTranscriptionEvents.VadDetected);
|
||||
|
||||
ep.removeCustomEventListener(NuanceTranscriptionEvents.Transcription);
|
||||
ep.removeCustomEventListener(NuanceTranscriptionEvents.TranscriptionComplete);
|
||||
ep.removeCustomEventListener(NuanceTranscriptionEvents.StartOfSpeech);
|
||||
ep.removeCustomEventListener(NuanceTranscriptionEvents.VadDetected);
|
||||
|
||||
ep.removeCustomEventListener(DeepgramTranscriptionEvents.Transcription);
|
||||
ep.removeCustomEventListener(DeepgramTranscriptionEvents.Connect);
|
||||
ep.removeCustomEventListener(DeepgramTranscriptionEvents.ConnectFailure);
|
||||
|
||||
ep.removeCustomEventListener(SonioxTranscriptionEvents.Transcription);
|
||||
|
||||
ep.removeCustomEventListener(NvidiaTranscriptionEvents.Transcription);
|
||||
ep.removeCustomEventListener(NvidiaTranscriptionEvents.TranscriptionComplete);
|
||||
ep.removeCustomEventListener(NvidiaTranscriptionEvents.StartOfSpeech);
|
||||
ep.removeCustomEventListener(NvidiaTranscriptionEvents.VadDetected);
|
||||
|
||||
ep.removeCustomEventListener(JambonzTranscriptionEvents.Transcription);
|
||||
ep.removeCustomEventListener(JambonzTranscriptionEvents.Connect);
|
||||
ep.removeCustomEventListener(JambonzTranscriptionEvents.ConnectFailure);
|
||||
|
||||
ep.removeCustomEventListener(JambonzTranscriptionEvents.Error);
|
||||
};
|
||||
|
||||
const setSpeechCredentialsAtRuntime = (recognizer) => {
|
||||
if (!recognizer) return;
|
||||
if (recognizer.vendor === 'nuance') {
|
||||
const {clientId, secret, kryptonEndpoint} = recognizer.nuanceOptions || {};
|
||||
if (clientId && secret) return {client_id: clientId, secret};
|
||||
if (kryptonEndpoint) return {nuance_stt_uri: kryptonEndpoint};
|
||||
}
|
||||
else if (recognizer.vendor === 'nvidia') {
|
||||
const {rivaUri} = recognizer.nvidiaOptions || {};
|
||||
if (rivaUri) return {riva_uri: rivaUri};
|
||||
}
|
||||
else if (recognizer.vendor === 'deepgram') {
|
||||
const {apiKey} = recognizer.deepgramOptions || {};
|
||||
if (apiKey) return {api_key: apiKey};
|
||||
}
|
||||
else if (recognizer.vendor === 'soniox') {
|
||||
const {apiKey} = recognizer.sonioxOptions || {};
|
||||
if (apiKey) return {api_key: apiKey};
|
||||
}
|
||||
else if (recognizer.vendor === 'ibm') {
|
||||
const {ttsApiKey, ttsRegion, sttApiKey, sttRegion, instanceId} = recognizer.ibmOptions || {};
|
||||
if (ttsApiKey || sttApiKey) return {
|
||||
tts_api_key: ttsApiKey,
|
||||
tts_region: ttsRegion,
|
||||
stt_api_key: sttApiKey,
|
||||
stt_region: sttRegion,
|
||||
instance_id: instanceId
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
normalizeTranscription,
|
||||
setChannelVarsForStt,
|
||||
removeSpeechListeners,
|
||||
setSpeechCredentialsAtRuntime,
|
||||
compileSonioxTranscripts
|
||||
};
|
||||
};
|
||||
@@ -4,9 +4,12 @@ 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;
|
||||
const {
|
||||
RESPONSE_TIMEOUT_MS,
|
||||
MAX_RECONNECTS,
|
||||
JAMBONES_WS_HANDSHAKE_TIMEOUT_MS,
|
||||
JAMBONES_WS_MAX_PAYLOAD
|
||||
} = require('../config');
|
||||
|
||||
class WsRequestor extends BaseRequestor {
|
||||
constructor(logger, account_sid, hook, secret) {
|
||||
@@ -45,7 +48,7 @@ class WsRequestor extends BaseRequestor {
|
||||
return;
|
||||
}
|
||||
if (this.closedGracefully) {
|
||||
this.logger.debug(`WsRequestor:request - discarding ${type} because we closed the socket`);
|
||||
this.logger.debug(`WsRequestor:request - discarding ${type} because socket was closed gracefully`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -53,8 +56,14 @@ class WsRequestor extends BaseRequestor {
|
||||
|
||||
/* if we have an absolute url, and it is http then do a standard webhook */
|
||||
if (this._isAbsoluteUrl(url) && url.startsWith('http')) {
|
||||
const HttpRequestor = require('./http-requestor');
|
||||
this.logger.debug({hook}, 'WsRequestor: sending a webhook (HTTP)');
|
||||
const requestor = new HttpRequestor(this.logger, this.account_sid, hook, this.secret);
|
||||
const h = typeof hook === 'object' ? hook : {url: hook};
|
||||
const requestor = new HttpRequestor(this.logger, this.account_sid, h, this.secret);
|
||||
if (type === 'session:redirect') {
|
||||
this.close();
|
||||
this.emit('handover', requestor);
|
||||
}
|
||||
return requestor.request(type, hook, params, httpHeaders);
|
||||
}
|
||||
|
||||
@@ -69,7 +78,7 @@ class WsRequestor extends BaseRequestor {
|
||||
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}`);
|
||||
return Promise.reject(`max attempts connecting to ${this.url}`);
|
||||
}
|
||||
try {
|
||||
const startAt = process.hrtime();
|
||||
@@ -79,7 +88,7 @@ class WsRequestor extends BaseRequestor {
|
||||
} catch (err) {
|
||||
this.logger.info({url, err}, 'WsRequestor:request - failed connecting');
|
||||
this.connectInProgress = false;
|
||||
throw err;
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
assert(this.ws);
|
||||
@@ -91,6 +100,9 @@ class WsRequestor extends BaseRequestor {
|
||||
assert.ok(url, 'WsRequestor:request url was not provided');
|
||||
|
||||
const msgid = short.generate();
|
||||
// save initial msgid in case we need to reconnect during initial session:new
|
||||
if (type === 'session:new') this._initMsgId = msgid;
|
||||
|
||||
const b3 = httpHeaders?.b3 ? {b3: httpHeaders.b3} : {};
|
||||
const obj = {
|
||||
type,
|
||||
@@ -113,8 +125,18 @@ class WsRequestor extends BaseRequestor {
|
||||
|
||||
//this.logger.debug({obj}, `websocket: sending (${url})`);
|
||||
|
||||
/* special case: reconnecting before we received ack to session:new */
|
||||
let reconnectingWithoutAck = false;
|
||||
if (type === 'session:reconnect' && this._initMsgId) {
|
||||
reconnectingWithoutAck = true;
|
||||
const obj = this.messagesInFlight.get(this._initMsgId);
|
||||
this.messagesInFlight.delete(this._initMsgId);
|
||||
this.messagesInFlight.set(msgid, obj);
|
||||
this._initMsgId = msgid;
|
||||
}
|
||||
|
||||
/* simple notifications */
|
||||
if (['call:status', 'jambonz:error', 'session:reconnect'].includes(type)) {
|
||||
if (['call:status', 'verb:status', 'jambonz:error'].includes(type) || reconnectingWithoutAck) {
|
||||
this.ws.send(JSON.stringify(obj), () => {
|
||||
this.logger.debug({obj}, `WsRequestor:request websocket: sent (${url})`);
|
||||
sendQueuedMsgs();
|
||||
@@ -126,7 +148,7 @@ class WsRequestor extends BaseRequestor {
|
||||
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);
|
||||
const {failure} = this.messagesInFlight.get(msgid) || {};
|
||||
failure && failure(`timeout from far end for msgid ${msgid}`);
|
||||
this.messagesInFlight.delete(msgid);
|
||||
}, RESPONSE_TIMEOUT_MS);
|
||||
@@ -138,7 +160,7 @@ class WsRequestor extends BaseRequestor {
|
||||
success: (response) => {
|
||||
clearTimeout(timer);
|
||||
const rtt = this._roundTrip(startAt);
|
||||
this.logger.info({response}, `WsRequestor:request ${url} succeeded in ${rtt}ms`);
|
||||
this.logger.debug({response}, `WsRequestor:request ${url} succeeded in ${rtt}ms`);
|
||||
this.stats.histogram('app.hook.ws_response_time', rtt, ['hook_type:app']);
|
||||
resolve(response);
|
||||
},
|
||||
@@ -158,20 +180,14 @@ class WsRequestor extends BaseRequestor {
|
||||
|
||||
close() {
|
||||
this.closedGracefully = true;
|
||||
this.logger.info('WsRequestor:close closing socket');
|
||||
this.logger.debug('WsRequestor:close closing socket');
|
||||
try {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws.close(1000);
|
||||
this.ws.removeAllListeners();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
this._clearPendingMessages();
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'WsRequestor: Error closing socket');
|
||||
}
|
||||
@@ -180,14 +196,14 @@ class WsRequestor extends BaseRequestor {
|
||||
_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) :
|
||||
const handshakeTimeout = JAMBONES_WS_HANDSHAKE_TIMEOUT_MS ?
|
||||
parseInt(JAMBONES_WS_HANDSHAKE_TIMEOUT_MS) :
|
||||
1500;
|
||||
let opts = {
|
||||
followRedirects: true,
|
||||
maxRedirects: 2,
|
||||
handshakeTimeout,
|
||||
maxPayload: 8096,
|
||||
maxPayload: JAMBONES_WS_MAX_PAYLOAD ? parseInt(JAMBONES_WS_MAX_PAYLOAD) : 24 * 1024,
|
||||
};
|
||||
if (this.username && this.password) opts = {...opts, auth: `${this.username}:${this.password}`};
|
||||
|
||||
@@ -207,7 +223,6 @@ class WsRequestor extends BaseRequestor {
|
||||
}
|
||||
|
||||
_setHandlers(ws) {
|
||||
this.logger.debug('WsRequestor:_setHandlers');
|
||||
ws
|
||||
.once('open', this._onOpen.bind(this, ws))
|
||||
.once('close', this._onClose.bind(this))
|
||||
@@ -216,6 +231,15 @@ class WsRequestor extends BaseRequestor {
|
||||
.on('error', this._onError.bind(this));
|
||||
}
|
||||
|
||||
_clearPendingMessages() {
|
||||
for (const [msgid, obj] of this.messagesInFlight) {
|
||||
const {timer} = obj;
|
||||
clearTimeout(timer);
|
||||
if (!this._initMsgId) obj.failure(`abandoning msgid ${msgid} since socket is closed`);
|
||||
}
|
||||
this.messagesInFlight.clear();
|
||||
}
|
||||
|
||||
_onError(err) {
|
||||
if (this.connections > 0) {
|
||||
this.logger.info({url: this.url, err}, 'WsRequestor:_onError');
|
||||
@@ -253,12 +277,14 @@ class WsRequestor extends BaseRequestor {
|
||||
}, 'WsRequestor - unexpected response');
|
||||
this.emit('connection-failure');
|
||||
this.emit('not-ready', new Error(`${res.statusCode} ${res.statusMessage}`));
|
||||
this.connections++;
|
||||
}
|
||||
|
||||
_onSocketClosed() {
|
||||
this.ws = null;
|
||||
this.emit('connection-dropped');
|
||||
if (this.connections > 0 && this.connections < MAX_RECONNECTS && !this.closedGracefully) {
|
||||
if (!this._initMsgId) this._clearPendingMessages();
|
||||
this.logger.debug(`WsRequestor:_onSocketClosed waiting ${this.backoffMs} to reconnect`);
|
||||
setTimeout(() => {
|
||||
this.logger.debug(
|
||||
@@ -286,7 +312,7 @@ class WsRequestor extends BaseRequestor {
|
||||
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');
|
||||
//this.logger.debug({obj}, 'WsRequestor:request websocket: received');
|
||||
assert.ok(type, 'type property not supplied');
|
||||
|
||||
switch (type) {
|
||||
@@ -310,12 +336,13 @@ class WsRequestor extends BaseRequestor {
|
||||
}
|
||||
|
||||
_recvAck(msgid, data) {
|
||||
this._initMsgId = null;
|
||||
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.logger.debug({url: this.url}, `WsRequestor:_recvAck - received response to ${msgid}`);
|
||||
this.messagesInFlight.delete(msgid);
|
||||
const {success} = obj;
|
||||
success && success(data);
|
||||
@@ -323,7 +350,7 @@ class WsRequestor extends BaseRequestor {
|
||||
|
||||
_recvCommand(msgid, command, call_sid, queueCommand, data) {
|
||||
// TODO: validate command
|
||||
this.logger.info({msgid, command, call_sid, queueCommand, data}, 'received command');
|
||||
this.logger.debug({msgid, command, call_sid, queueCommand, data}, 'received command');
|
||||
this.emit('command', {msgid, command, call_sid, queueCommand, data});
|
||||
}
|
||||
}
|
||||
|
||||
10763
package-lock.json
generated
10763
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
70
package.json
70
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jambonz-feature-server",
|
||||
"version": "v0.7.5",
|
||||
"version": "0.8.3",
|
||||
"main": "app.js",
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
@@ -16,56 +16,60 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/jambonz/jambonz-feature-server.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/jambonz/jambonz-feature-server/issues"
|
||||
},
|
||||
"bugs": {},
|
||||
"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=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/ ",
|
||||
"test": "NODE_ENV=test JAMBONES_HOSTING=1 HTTP_POOL=1 ENCRYPTION_SECRET=foobar DRACHTIO_HOST=127.0.0.1 DRACHTIO_PORT=9060 DRACHTIO_SECRET=cymru JAMBONES_MYSQL_HOST=127.0.0.1 JAMBONES_MYSQL_PORT=3360 JAMBONES_MYSQL_USER=jambones_test JAMBONES_MYSQL_PASSWORD=jambones_test JAMBONES_MYSQL_DATABASE=jambones_test JAMBONES_REDIS_HOST=127.0.0.1 JAMBONES_REDIS_PORT=16379 JAMBONES_LOGLEVEL=error ENABLE_METRICS=0 HTTP_PORT=3000 JAMBONES_SBCS=172.38.0.10 JAMBONES_FREESWITCH=127.0.0.1:8022:JambonzR0ck$:docker-host JAMBONES_TIME_SERIES_HOST=127.0.0.1 JAMBONES_NETWORK_CIDR=172.38.0.0/16 node test/ ",
|
||||
"coverage": "./node_modules/.bin/nyc --reporter html --report-dir ./coverage npm run test",
|
||||
"jslint": "eslint app.js lib"
|
||||
"jslint": "eslint app.js tracer.js lib",
|
||||
"jslint:fix": "eslint app.js tracer.js lib --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jambonz/db-helpers": "^0.6.18",
|
||||
"@jambonz/db-helpers": "^0.8.1",
|
||||
"@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",
|
||||
"@jambonz/realtimedb-helpers": "^0.7.2",
|
||||
"@jambonz/speech-utils": "^0.0.13",
|
||||
"@jambonz/stats-collector": "^0.1.8",
|
||||
"@jambonz/time-series": "^0.2.5",
|
||||
"@jambonz/verb-specifications": "^0.0.22",
|
||||
"@opentelemetry/api": "^1.4.0",
|
||||
"@opentelemetry/exporter-jaeger": "^1.9.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.35.0",
|
||||
"@opentelemetry/exporter-zipkin": "^1.9.0",
|
||||
"@opentelemetry/instrumentation": "^0.35.0",
|
||||
"@opentelemetry/resources": "^1.9.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.9.0",
|
||||
"@opentelemetry/sdk-trace-node": "^1.9.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.9.0",
|
||||
"aws-sdk": "^2.1313.0",
|
||||
"bent": "^7.3.12",
|
||||
"debug": "^4.3.4",
|
||||
"deepcopy": "^2.1.0",
|
||||
"drachtio-fsmrf": "^3.0.1",
|
||||
"drachtio-srf": "^4.5.1",
|
||||
"express": "^4.18.1",
|
||||
"helmet": "^5.1.0",
|
||||
"drachtio-fsmrf": "^3.0.21",
|
||||
"drachtio-srf": "^4.5.23",
|
||||
"express": "^4.18.2",
|
||||
"ip": "^1.1.8",
|
||||
"moment": "^2.29.3",
|
||||
"parse-url": "^7.0.2",
|
||||
"pino": "^6.14.0",
|
||||
"moment": "^2.29.4",
|
||||
"parse-url": "^8.1.0",
|
||||
"pino": "^8.8.0",
|
||||
"polly-ssml-split": "^0.1.0",
|
||||
"proxyquire": "^2.1.3",
|
||||
"sdp-transform": "^2.14.1",
|
||||
"short-uuid": "^4.2.0",
|
||||
"short-uuid": "^4.2.2",
|
||||
"sinon": "^15.0.1",
|
||||
"to-snake-case": "^1.0.0",
|
||||
"uuid": "^8.3.2",
|
||||
"verify-aws-sns-signature": "^0.0.7",
|
||||
"ws": "^8.8.0",
|
||||
"xml2js": "^0.4.23"
|
||||
"undici": "^5.19.1",
|
||||
"uuid-random": "^1.3.2",
|
||||
"verify-aws-sns-signature": "^0.1.0",
|
||||
"ws": "^8.9.0",
|
||||
"xml2js": "^0.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"clear-module": "^4.1.2",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-plugin-promise": "^4.3.1",
|
||||
"nyc": "^15.1.0",
|
||||
"tape": "^5.5.3"
|
||||
"tape": "^5.6.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.0.6",
|
||||
|
||||
102
test/config-test.js
Normal file
102
test/config-test.js
Normal file
@@ -0,0 +1,102 @@
|
||||
const test = require('tape');
|
||||
const { sippUac } = require('./sipp')('test_fs');
|
||||
const bent = require('bent');
|
||||
const getJSON = bent('json')
|
||||
const clearModule = require('clear-module');
|
||||
const {provisionCallHook} = require('./utils')
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
});
|
||||
|
||||
function connect(connectable) {
|
||||
return new Promise((resolve, reject) => {
|
||||
connectable.on('connect', () => {
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('\'config: listen\'', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
// GIVEN
|
||||
const from = "config_listen_success";
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "config",
|
||||
"listen": {
|
||||
"enable": true,
|
||||
"url": `ws://172.38.0.60:3000/${from}`
|
||||
}
|
||||
},
|
||||
{
|
||||
"verb": "pause",
|
||||
"length": 5
|
||||
}
|
||||
];
|
||||
|
||||
provisionCallHook(from, verbs);
|
||||
|
||||
// THEN
|
||||
await sippUac('uac-gather-account-creds-success-send-bye.xml', '172.38.0.10', from);
|
||||
let obj = await getJSON(`http://127.0.0.1:3100/ws_packet_count/${from}`);
|
||||
t.pass('config: successfully started background listen');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'config: listen - stop\'', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
// GIVEN
|
||||
const from = "config_listen_success";
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "config",
|
||||
"listen": {
|
||||
"enable": true,
|
||||
"url": `ws://172.38.0.60:3000/${from}`
|
||||
}
|
||||
},
|
||||
{
|
||||
"verb": "pause",
|
||||
"length": 1
|
||||
},
|
||||
{
|
||||
"verb": "config",
|
||||
"listen": {
|
||||
"enable": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"verb": "pause",
|
||||
"length": 3
|
||||
}
|
||||
];
|
||||
|
||||
provisionCallHook(from, verbs);
|
||||
|
||||
// THEN
|
||||
await sippUac('uac-gather-account-creds-success-send-bye.xml', '172.38.0.10', from);
|
||||
let obj = await getJSON(`http://127.0.0.1:3100/ws_packet_count/${from}`);
|
||||
t.pass('config: successfully started then stopped background listen');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
166
test/create-call-test.js
Normal file
166
test/create-call-test.js
Normal file
@@ -0,0 +1,166 @@
|
||||
const test = require('tape');
|
||||
const { sippUac } = require('./sipp')('test_fs');
|
||||
const bent = require('bent');
|
||||
const clearModule = require('clear-module');
|
||||
const {provisionCallHook} = require('./utils')
|
||||
const getJSON = bent('json')
|
||||
|
||||
const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
});
|
||||
|
||||
function connect(connectable) {
|
||||
return new Promise((resolve, reject) => {
|
||||
connectable.on('connect', () => {
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('test create-call timeout', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
// give UAS app time to come up
|
||||
const p = sippUac('uas-timeout-cancel.xml', '172.38.0.10');
|
||||
await waitFor(1000);
|
||||
|
||||
// GIVEN
|
||||
let account_sid = '622f62e4-303a-49f2-bbe0-eb1e1714e37a';
|
||||
const post = bent('http://127.0.0.1:3000/', 'POST', 'json', 201);
|
||||
post('v1/createCall', {
|
||||
'account_sid':account_sid,
|
||||
'timeout': 1,
|
||||
"call_hook": {
|
||||
"url": "https://public-apps.jambonz.us/hello-world",
|
||||
"method": "POST"
|
||||
},
|
||||
"from": "15083718299",
|
||||
"to": {
|
||||
"type": "phone",
|
||||
"number": "15583084809"
|
||||
}});
|
||||
//THEN
|
||||
await p;
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('test create-call call-hook basic authentication', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
|
||||
// GIVEN
|
||||
let from = 'call_hook_basic_authentication';
|
||||
let account_sid = '622f62e4-303a-49f2-bbe0-eb1e1714e37a';
|
||||
|
||||
// Give UAS app time to come up
|
||||
const p = sippUac('uas.xml', '172.38.0.10', from);
|
||||
await waitFor(1000);
|
||||
|
||||
const post = bent('http://127.0.0.1:3000/', 'POST', 'json', 201);
|
||||
post('v1/createCall', {
|
||||
'account_sid':account_sid,
|
||||
"call_hook": {
|
||||
"url": "http://127.0.0.1:3100/",
|
||||
"method": "POST",
|
||||
"username": "username",
|
||||
"password": "password"
|
||||
},
|
||||
"from": from,
|
||||
"to": {
|
||||
"type": "phone",
|
||||
"number": "15583084809"
|
||||
}});
|
||||
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "say",
|
||||
"text": "hello"
|
||||
}
|
||||
];
|
||||
provisionCallHook(from, verbs);
|
||||
//THEN
|
||||
await p;
|
||||
|
||||
let obj = await getJSON(`http:127.0.0.1:3100/lastRequest/${from}`)
|
||||
t.ok(obj.headers.Authorization = 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=',
|
||||
'create-call: call-hook contains basic authentication header');
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('test create-call amd', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
|
||||
// GIVEN
|
||||
let from = 'create-call-amd';
|
||||
let account_sid = 'bb845d4b-83a9-4cde-a6e9-50f3743bab3f';
|
||||
|
||||
// Give UAS app time to come up
|
||||
const p = sippUac('uas.xml', '172.38.0.10', from);
|
||||
await waitFor(1000);
|
||||
|
||||
const post = bent('http://127.0.0.1:3000/', 'POST', 'json', 201);
|
||||
post('v1/createCall', {
|
||||
'account_sid':account_sid,
|
||||
"call_hook": {
|
||||
"url": "http://127.0.0.1:3100/",
|
||||
"method": "POST",
|
||||
"username": "username",
|
||||
"password": "password"
|
||||
},
|
||||
"from": from,
|
||||
"to": {
|
||||
"type": "phone",
|
||||
"number": "15583084809"
|
||||
},
|
||||
"amd": {
|
||||
"actionHook": "/actionHook"
|
||||
},
|
||||
"speech_recognizer_vendor": "google",
|
||||
"speech_recognizer_language": "en"
|
||||
});
|
||||
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "pause",
|
||||
"length": 7
|
||||
}
|
||||
];
|
||||
provisionCallHook(from, verbs);
|
||||
//THEN
|
||||
await p;
|
||||
|
||||
let obj = await getJSON(`http:127.0.0.1:3100/lastRequest/${from}_actionHook`)
|
||||
t.ok(obj.body.type = 'amd_no_speech_detected',
|
||||
'create-call: AMD detected');
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,14 @@ const test = require('tape') ;
|
||||
const exec = require('child_process').exec ;
|
||||
const fs = require('fs');
|
||||
const {encrypt} = require('../lib/utils/encrypt-decrypt');
|
||||
const {
|
||||
GCP_JSON_KEY,
|
||||
AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY,
|
||||
AWS_REGION,
|
||||
MICROSOFT_REGION,
|
||||
MICROSOFT_API_KEY,
|
||||
} = require('../lib/config');
|
||||
|
||||
test('creating jambones_test database', (t) => {
|
||||
exec(`mysql -h 127.0.0.1 -u root --protocol=tcp --port=3360 < ${__dirname}/db/create_test_db.sql`, (err, stdout, stderr) => {
|
||||
@@ -18,24 +26,38 @@ test('creating schema', (t) => {
|
||||
if (err) return t.end(err);
|
||||
t.pass('schema and test data successfully created');
|
||||
|
||||
if (process.env.GCP_JSON_KEY && process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
|
||||
const google_credential = encrypt(process.env.GCP_JSON_KEY);
|
||||
const sql = [];
|
||||
if (GCP_JSON_KEY) {
|
||||
const google_credential = encrypt(GCP_JSON_KEY);
|
||||
t.pass('adding google credentials');
|
||||
sql.push(`UPDATE speech_credentials SET credential='${google_credential}' WHERE vendor='google';`);
|
||||
}
|
||||
if (AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY) {
|
||||
const aws_credential = encrypt(JSON.stringify({
|
||||
access_key_id: process.env.AWS_ACCESS_KEY_ID,
|
||||
secret_access_key: process.env.AWS_SECRET_ACCESS_KEY
|
||||
access_key_id: AWS_ACCESS_KEY_ID,
|
||||
secret_access_key: AWS_SECRET_ACCESS_KEY,
|
||||
aws_region: AWS_REGION
|
||||
}));
|
||||
const cmd = `
|
||||
UPDATE speech_credentials SET credential='${google_credential}' WHERE vendor='google';
|
||||
UPDATE speech_credentials SET credential='${aws_credential}' WHERE vendor='aws';
|
||||
`;
|
||||
t.pass('adding aws credentials');
|
||||
sql.push(`UPDATE speech_credentials SET credential='${aws_credential}' WHERE vendor='aws';`);
|
||||
}
|
||||
if (MICROSOFT_REGION && MICROSOFT_API_KEY) {
|
||||
const microsoft_credential = encrypt(JSON.stringify({
|
||||
region: MICROSOFT_REGION,
|
||||
api_key: MICROSOFT_API_KEY
|
||||
}));
|
||||
t.pass('adding microsoft credentials');
|
||||
sql.push(`UPDATE speech_credentials SET credential='${microsoft_credential}' WHERE vendor='microsoft';`);
|
||||
}
|
||||
if (sql.length > 0) {
|
||||
const path = `${__dirname}/.creds.sql`;
|
||||
fs.writeFileSync(path, cmd);
|
||||
const cmd = sql.join('\n');
|
||||
fs.writeFileSync(path, sql.join('\n'));
|
||||
exec(`mysql -h 127.0.0.1 -u root --protocol=tcp --port=3360 -D jambones_test < ${path}`, (err, stdout, stderr) => {
|
||||
console.log(stdout);
|
||||
console.log(stderr);
|
||||
if (err) return t.end(err);
|
||||
fs.unlinkSync(path)
|
||||
fs.writeFileSync(`${__dirname}/credentials/gcp.json`, process.env.GCP_JSON_KEY);
|
||||
t.pass('set account-level speech credentials');
|
||||
t.end();
|
||||
});
|
||||
|
||||
9
test/data/bad/bad-say-ssml.json
Normal file
9
test/data/bad/bad-say-ssml.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"say": {
|
||||
"text": "<speak>I already told you <emphasis level=\"strong\">I already told you I already told you I already told you I already told you! I already told you I already told you I already told you I already told you? I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you told I already told you I already told you told I already told you I already told you. I already told you <break time=\"3s\"/> I really like that person!</emphasis> this is another long text.</speak>",
|
||||
"synthesizer": {
|
||||
"vendor": "google",
|
||||
"language": "en-US"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
test/data/good/say-ssml.json
Normal file
9
test/data/good/say-ssml.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"say": {
|
||||
"text": "<speak>I already told you I already told you I already told you I already told you I already told you! I already told you I already told you I already told you I already told you? I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you told I already told you I already told you told I already told you I already told you. I already told you <break time=\"3s\"/> I <emphasis level=\"strong\">really like that person!</emphasis> this is another long text.</speak>",
|
||||
"synthesizer": {
|
||||
"vendor": "google",
|
||||
"language": "en-US"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,6 +217,7 @@ CREATE TABLE `applications` (
|
||||
`call_hook_sid` char(36) DEFAULT NULL COMMENT 'webhook to call for inbound calls ',
|
||||
`call_status_hook_sid` char(36) DEFAULT NULL COMMENT 'webhook to call for call status events',
|
||||
`messaging_hook_sid` char(36) DEFAULT NULL COMMENT 'webhook to call for inbound SMS/MMS ',
|
||||
`app_json` VARCHAR(16384),
|
||||
`speech_synthesis_vendor` varchar(64) NOT NULL DEFAULT 'google',
|
||||
`speech_synthesis_language` varchar(12) NOT NULL DEFAULT 'en-US',
|
||||
`speech_synthesis_voice` varchar(64) DEFAULT NULL,
|
||||
@@ -245,13 +246,14 @@ CREATE TABLE `applications` (
|
||||
|
||||
LOCK TABLES `applications` WRITE;
|
||||
/*!40000 ALTER TABLE `applications` DISABLE KEYS */;
|
||||
INSERT INTO `applications` VALUES ('0dddaabf-0a30-43e3-84e8-426873b1a78b','decline call',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','c71e79db-24f2-4866-a3ee-febb0f97b341','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US');
|
||||
INSERT INTO `applications` VALUES ('308b4f41-1a18-4052-b89a-c054e75ce242','say',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','54ab0976-a6c0-45d8-89a4-d90d45bf9d96','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US');
|
||||
INSERT INTO `applications` VALUES ('24d0f6af-e976-44dd-a2e8-41c7b55abe33','say account 2',NULL,'622f62e4-303a-49f2-bbe0-eb1e1714e37a','54ab0976-a6c0-45d8-89a4-d90d45bf9d96','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US');
|
||||
INSERT INTO `applications` VALUES ('17461c69-56b5-4dab-ad83-1c43a0f93a3d','gather',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','10692465-a511-4277-9807-b7157e4f81e1','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US');
|
||||
INSERT INTO `applications` VALUES ('baf9213b-5556-4c20-870c-586392ed246f','transcribe',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','ecb67a8f-f7ce-4919-abf0-bbc69c1001e5','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US');
|
||||
INSERT INTO `applications` VALUES ('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');
|
||||
INSERT INTO `applications` VALUES ('0dddaabf-0a30-43e3-84e8-426873b1a78b','decline call',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','c71e79db-24f2-4866-a3ee-febb0f97b341','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,NULL,'google','en-US','en-US-Standard-C','google','en-US');
|
||||
INSERT INTO `applications` VALUES ('308b4f41-1a18-4052-b89a-c054e75ce242','say',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','54ab0976-a6c0-45d8-89a4-d90d45bf9d96','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,NULL,'google','en-US','en-US-Standard-C','google','en-US');
|
||||
INSERT INTO `applications` VALUES ('24d0f6af-e976-44dd-a2e8-41c7b55abe33','say account 2',NULL,'622f62e4-303a-49f2-bbe0-eb1e1714e37a','54ab0976-a6c0-45d8-89a4-d90d45bf9d96','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,NULL,'google','en-US','en-US-Standard-C','google','en-US');
|
||||
INSERT INTO `applications` VALUES ('17461c69-56b5-4dab-ad83-1c43a0f93a3d','gather',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','10692465-a511-4277-9807-b7157e4f81e1','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,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,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,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,NULL,'google','en-US','en-US-Standard-C','google','en-US');
|
||||
INSERT INTO `applications` VALUES ('0dddaabf-0a30-43e3-84e8-426873b1a78c','app json',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','c71e79db-24f2-4866-a3ee-febb0f97b341','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'[{"verb": "play","url": "silence_stream://5000"}]','google','en-US','en-US-Standard-C','google','en-US');
|
||||
/*!40000 ALTER TABLE `applications` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
|
||||
@@ -451,6 +453,7 @@ INSERT INTO `phone_numbers` VALUES ('05eeed62-b29b-4679-bf38-d7a4e318be44','1617
|
||||
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);
|
||||
INSERT INTO `phone_numbers` VALUES ('964d0581-9627-44cb-be20-8118050406b3','16174000007','5145b436-2f38-4029-8d4c-fd8c67831c7a','bb845d4b-83a9-4cde-a6e9-50f3743bab3f','0dddaabf-0a30-43e3-84e8-426873b1a78c', NULL);
|
||||
/*!40000 ALTER TABLE `phone_numbers` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
|
||||
@@ -614,7 +617,10 @@ 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',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);
|
||||
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),
|
||||
('2add347f-34f2-45c6-a016-f955d218ffb6',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','microsoft','credential-goes-here',1,1,NULL,'2021-04-03 15:42:10',1,1),
|
||||
('84154212-5c99-4c94-8993-bc2a46288daa',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','aws','credential-goes-here',1,1,NULL,NULL,NULL,NULL);
|
||||
/*!40000 ALTER TABLE `speech_credentials` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ SET FOREIGN_KEY_CHECKS=0;
|
||||
|
||||
DROP TABLE IF EXISTS account_static_ips;
|
||||
|
||||
DROP TABLE IF EXISTS account_limits;
|
||||
|
||||
DROP TABLE IF EXISTS account_products;
|
||||
|
||||
DROP TABLE IF EXISTS account_subscriptions;
|
||||
@@ -18,6 +20,12 @@ DROP TABLE IF EXISTS lcr_carrier_set_entry;
|
||||
|
||||
DROP TABLE IF EXISTS lcr_routes;
|
||||
|
||||
DROP TABLE IF EXISTS password_settings;
|
||||
|
||||
DROP TABLE IF EXISTS user_permissions;
|
||||
|
||||
DROP TABLE IF EXISTS permissions;
|
||||
|
||||
DROP TABLE IF EXISTS predefined_sip_gateways;
|
||||
|
||||
DROP TABLE IF EXISTS predefined_smpp_gateways;
|
||||
@@ -36,6 +44,8 @@ DROP TABLE IF EXISTS sbc_addresses;
|
||||
|
||||
DROP TABLE IF EXISTS ms_teams_tenants;
|
||||
|
||||
DROP TABLE IF EXISTS service_provider_limits;
|
||||
|
||||
DROP TABLE IF EXISTS signup_history;
|
||||
|
||||
DROP TABLE IF EXISTS smpp_addresses;
|
||||
@@ -69,6 +79,15 @@ private_ipv4 VARBINARY(16) NOT NULL UNIQUE ,
|
||||
PRIMARY KEY (account_static_ip_sid)
|
||||
);
|
||||
|
||||
CREATE TABLE account_limits
|
||||
(
|
||||
account_limits_sid CHAR(36) NOT NULL UNIQUE ,
|
||||
account_sid CHAR(36) NOT NULL,
|
||||
category ENUM('api_rate','voice_call_session', 'device','voice_call_minutes','voice_call_session_license', 'voice_call_minutes_license') NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
PRIMARY KEY (account_limits_sid)
|
||||
);
|
||||
|
||||
CREATE TABLE account_subscriptions
|
||||
(
|
||||
account_subscription_sid CHAR(36) NOT NULL UNIQUE ,
|
||||
@@ -123,6 +142,21 @@ priority INTEGER NOT NULL UNIQUE COMMENT 'lower priority routes are attempted f
|
||||
PRIMARY KEY (lcr_route_sid)
|
||||
) COMMENT='Least cost routing table';
|
||||
|
||||
CREATE TABLE password_settings
|
||||
(
|
||||
min_password_length INTEGER NOT NULL DEFAULT 8,
|
||||
require_digit BOOLEAN NOT NULL DEFAULT false,
|
||||
require_special_character BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
CREATE TABLE permissions
|
||||
(
|
||||
permission_sid CHAR(36) NOT NULL UNIQUE ,
|
||||
name VARCHAR(32) NOT NULL UNIQUE ,
|
||||
description VARCHAR(255),
|
||||
PRIMARY KEY (permission_sid)
|
||||
);
|
||||
|
||||
CREATE TABLE predefined_carriers
|
||||
(
|
||||
predefined_carrier_sid CHAR(36) NOT NULL UNIQUE ,
|
||||
@@ -228,6 +262,15 @@ tenant_fqdn VARCHAR(255) NOT NULL UNIQUE ,
|
||||
PRIMARY KEY (ms_teams_tenant_sid)
|
||||
) COMMENT='A Microsoft Teams customer tenant';
|
||||
|
||||
CREATE TABLE service_provider_limits
|
||||
(
|
||||
service_provider_limits_sid CHAR(36) NOT NULL UNIQUE ,
|
||||
service_provider_sid CHAR(36) NOT NULL,
|
||||
category ENUM('api_rate','voice_call_session', 'device','voice_call_minutes','voice_call_session_license', 'voice_call_minutes_license') NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
PRIMARY KEY (service_provider_limits_sid)
|
||||
);
|
||||
|
||||
CREATE TABLE signup_history
|
||||
(
|
||||
email VARCHAR(255) NOT NULL,
|
||||
@@ -283,6 +326,7 @@ email_activation_code VARCHAR(16),
|
||||
email_validated BOOLEAN NOT NULL DEFAULT false,
|
||||
phone_validated BOOLEAN NOT NULL DEFAULT false,
|
||||
email_content_opt_out BOOLEAN NOT NULL DEFAULT false,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
PRIMARY KEY (user_sid)
|
||||
);
|
||||
|
||||
@@ -310,9 +354,20 @@ smpp_password VARCHAR(64),
|
||||
smpp_enquire_link_interval INTEGER DEFAULT 0,
|
||||
smpp_inbound_system_id VARCHAR(255),
|
||||
smpp_inbound_password VARCHAR(64),
|
||||
register_from_user VARCHAR(128),
|
||||
register_from_domain VARCHAR(255),
|
||||
register_public_ip_in_contact BOOLEAN NOT NULL DEFAULT false,
|
||||
PRIMARY KEY (voip_carrier_sid)
|
||||
) COMMENT='A Carrier or customer PBX that can send or receive calls';
|
||||
|
||||
CREATE TABLE user_permissions
|
||||
(
|
||||
user_permissions_sid CHAR(36) NOT NULL UNIQUE ,
|
||||
user_sid CHAR(36) NOT NULL,
|
||||
permission_sid CHAR(36) NOT NULL,
|
||||
PRIMARY KEY (user_permissions_sid)
|
||||
);
|
||||
|
||||
CREATE TABLE smpp_gateways
|
||||
(
|
||||
smpp_gateway_sid CHAR(36) NOT NULL UNIQUE ,
|
||||
@@ -330,7 +385,7 @@ PRIMARY KEY (smpp_gateway_sid)
|
||||
CREATE TABLE phone_numbers
|
||||
(
|
||||
phone_number_sid CHAR(36) UNIQUE ,
|
||||
number VARCHAR(32) NOT NULL UNIQUE ,
|
||||
number VARCHAR(132) NOT NULL UNIQUE ,
|
||||
voip_carrier_sid CHAR(36),
|
||||
account_sid CHAR(36),
|
||||
application_sid CHAR(36),
|
||||
@@ -380,6 +435,7 @@ account_sid CHAR(36) COMMENT 'account that this application belongs to (if null,
|
||||
call_hook_sid CHAR(36) COMMENT 'webhook to call for inbound calls ',
|
||||
call_status_hook_sid CHAR(36) COMMENT 'webhook to call for call status events',
|
||||
messaging_hook_sid CHAR(36) COMMENT 'webhook to call for inbound SMS/MMS ',
|
||||
app_json VARCHAR(16384),
|
||||
speech_synthesis_vendor VARCHAR(64) NOT NULL DEFAULT 'google',
|
||||
speech_synthesis_language VARCHAR(12) NOT NULL DEFAULT 'en-US',
|
||||
speech_synthesis_voice VARCHAR(64),
|
||||
@@ -418,6 +474,11 @@ disable_cdrs BOOLEAN NOT NULL DEFAULT 0,
|
||||
trial_end_date DATETIME,
|
||||
deactivated_reason VARCHAR(255),
|
||||
device_to_call_ratio INTEGER NOT NULL DEFAULT 5,
|
||||
subspace_client_id VARCHAR(255),
|
||||
subspace_client_secret VARCHAR(255),
|
||||
subspace_sip_teleport_id VARCHAR(255),
|
||||
subspace_sip_teleport_destinations VARCHAR(255),
|
||||
siprec_hook_sid CHAR(36),
|
||||
PRIMARY KEY (account_sid)
|
||||
) COMMENT='An enterprise that uses the platform for comm services';
|
||||
|
||||
@@ -425,19 +486,23 @@ CREATE INDEX account_static_ip_sid_idx ON account_static_ips (account_static_ip_
|
||||
CREATE INDEX account_sid_idx ON account_static_ips (account_sid);
|
||||
ALTER TABLE account_static_ips ADD FOREIGN KEY account_sid_idxfk (account_sid) REFERENCES accounts (account_sid);
|
||||
|
||||
CREATE INDEX account_sid_idx ON account_limits (account_sid);
|
||||
ALTER TABLE account_limits ADD FOREIGN KEY account_sid_idxfk_1 (account_sid) REFERENCES accounts (account_sid) ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX account_subscription_sid_idx ON account_subscriptions (account_subscription_sid);
|
||||
CREATE INDEX account_sid_idx ON account_subscriptions (account_sid);
|
||||
ALTER TABLE account_subscriptions ADD FOREIGN KEY account_sid_idxfk_1 (account_sid) REFERENCES accounts (account_sid);
|
||||
ALTER TABLE account_subscriptions ADD FOREIGN KEY account_sid_idxfk_2 (account_sid) REFERENCES accounts (account_sid);
|
||||
|
||||
CREATE INDEX invite_code_idx ON beta_invite_codes (invite_code);
|
||||
CREATE INDEX call_route_sid_idx ON call_routes (call_route_sid);
|
||||
ALTER TABLE call_routes ADD FOREIGN KEY account_sid_idxfk_2 (account_sid) REFERENCES accounts (account_sid);
|
||||
ALTER TABLE call_routes ADD FOREIGN KEY account_sid_idxfk_3 (account_sid) REFERENCES accounts (account_sid);
|
||||
|
||||
ALTER TABLE call_routes ADD FOREIGN KEY application_sid_idxfk (application_sid) REFERENCES applications (application_sid);
|
||||
|
||||
CREATE INDEX dns_record_sid_idx ON dns_records (dns_record_sid);
|
||||
ALTER TABLE dns_records ADD FOREIGN KEY account_sid_idxfk_3 (account_sid) REFERENCES accounts (account_sid);
|
||||
ALTER TABLE dns_records ADD FOREIGN KEY account_sid_idxfk_4 (account_sid) REFERENCES accounts (account_sid);
|
||||
|
||||
CREATE INDEX permission_sid_idx ON permissions (permission_sid);
|
||||
CREATE INDEX predefined_carrier_sid_idx ON predefined_carriers (predefined_carrier_sid);
|
||||
CREATE INDEX predefined_sip_gateway_sid_idx ON predefined_sip_gateways (predefined_sip_gateway_sid);
|
||||
CREATE INDEX predefined_carrier_sid_idx ON predefined_sip_gateways (predefined_carrier_sid);
|
||||
@@ -456,14 +521,14 @@ ALTER TABLE account_products ADD FOREIGN KEY product_sid_idxfk (product_sid) REF
|
||||
|
||||
CREATE INDEX account_offer_sid_idx ON account_offers (account_offer_sid);
|
||||
CREATE INDEX account_sid_idx ON account_offers (account_sid);
|
||||
ALTER TABLE account_offers ADD FOREIGN KEY account_sid_idxfk_4 (account_sid) REFERENCES accounts (account_sid);
|
||||
ALTER TABLE account_offers ADD FOREIGN KEY account_sid_idxfk_5 (account_sid) REFERENCES accounts (account_sid);
|
||||
|
||||
CREATE INDEX product_sid_idx ON account_offers (product_sid);
|
||||
ALTER TABLE account_offers ADD FOREIGN KEY product_sid_idxfk_1 (product_sid) REFERENCES products (product_sid);
|
||||
|
||||
CREATE INDEX api_key_sid_idx ON api_keys (api_key_sid);
|
||||
CREATE INDEX account_sid_idx ON api_keys (account_sid);
|
||||
ALTER TABLE api_keys ADD FOREIGN KEY account_sid_idxfk_5 (account_sid) REFERENCES accounts (account_sid);
|
||||
ALTER TABLE api_keys ADD FOREIGN KEY account_sid_idxfk_6 (account_sid) REFERENCES accounts (account_sid);
|
||||
|
||||
CREATE INDEX service_provider_sid_idx ON api_keys (service_provider_sid);
|
||||
ALTER TABLE api_keys ADD FOREIGN KEY service_provider_sid_idxfk (service_provider_sid) REFERENCES service_providers (service_provider_sid);
|
||||
@@ -477,44 +542,53 @@ ALTER TABLE sbc_addresses ADD FOREIGN KEY service_provider_sid_idxfk_1 (service_
|
||||
CREATE INDEX ms_teams_tenant_sid_idx ON ms_teams_tenants (ms_teams_tenant_sid);
|
||||
ALTER TABLE ms_teams_tenants ADD FOREIGN KEY service_provider_sid_idxfk_2 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
|
||||
|
||||
ALTER TABLE ms_teams_tenants ADD FOREIGN KEY account_sid_idxfk_6 (account_sid) REFERENCES accounts (account_sid);
|
||||
ALTER TABLE ms_teams_tenants ADD FOREIGN KEY account_sid_idxfk_7 (account_sid) REFERENCES accounts (account_sid);
|
||||
|
||||
ALTER TABLE ms_teams_tenants ADD FOREIGN KEY application_sid_idxfk_1 (application_sid) REFERENCES applications (application_sid);
|
||||
|
||||
CREATE INDEX tenant_fqdn_idx ON ms_teams_tenants (tenant_fqdn);
|
||||
CREATE INDEX service_provider_sid_idx ON service_provider_limits (service_provider_sid);
|
||||
ALTER TABLE service_provider_limits ADD FOREIGN KEY service_provider_sid_idxfk_3 (service_provider_sid) REFERENCES service_providers (service_provider_sid) ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX email_idx ON signup_history (email);
|
||||
CREATE INDEX smpp_address_sid_idx ON smpp_addresses (smpp_address_sid);
|
||||
CREATE INDEX service_provider_sid_idx ON smpp_addresses (service_provider_sid);
|
||||
ALTER TABLE smpp_addresses ADD FOREIGN KEY service_provider_sid_idxfk_3 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
|
||||
ALTER TABLE smpp_addresses ADD FOREIGN KEY service_provider_sid_idxfk_4 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
|
||||
|
||||
CREATE UNIQUE INDEX speech_credentials_idx_1 ON speech_credentials (vendor,account_sid);
|
||||
|
||||
CREATE INDEX speech_credential_sid_idx ON speech_credentials (speech_credential_sid);
|
||||
CREATE INDEX service_provider_sid_idx ON speech_credentials (service_provider_sid);
|
||||
ALTER TABLE speech_credentials ADD FOREIGN KEY service_provider_sid_idxfk_4 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
|
||||
ALTER TABLE speech_credentials ADD FOREIGN KEY service_provider_sid_idxfk_5 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
|
||||
|
||||
CREATE INDEX account_sid_idx ON speech_credentials (account_sid);
|
||||
ALTER TABLE speech_credentials ADD FOREIGN KEY account_sid_idxfk_7 (account_sid) REFERENCES accounts (account_sid);
|
||||
ALTER TABLE speech_credentials ADD FOREIGN KEY account_sid_idxfk_8 (account_sid) REFERENCES accounts (account_sid);
|
||||
|
||||
CREATE INDEX user_sid_idx ON users (user_sid);
|
||||
CREATE INDEX email_idx ON users (email);
|
||||
CREATE INDEX phone_idx ON users (phone);
|
||||
CREATE INDEX account_sid_idx ON users (account_sid);
|
||||
ALTER TABLE users ADD FOREIGN KEY account_sid_idxfk_8 (account_sid) REFERENCES accounts (account_sid);
|
||||
ALTER TABLE users ADD FOREIGN KEY account_sid_idxfk_9 (account_sid) REFERENCES accounts (account_sid);
|
||||
|
||||
CREATE INDEX service_provider_sid_idx ON users (service_provider_sid);
|
||||
ALTER TABLE users ADD FOREIGN KEY service_provider_sid_idxfk_5 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
|
||||
ALTER TABLE users ADD FOREIGN KEY service_provider_sid_idxfk_6 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
|
||||
|
||||
CREATE INDEX email_activation_code_idx ON users (email_activation_code);
|
||||
CREATE INDEX voip_carrier_sid_idx ON voip_carriers (voip_carrier_sid);
|
||||
CREATE INDEX account_sid_idx ON voip_carriers (account_sid);
|
||||
ALTER TABLE voip_carriers ADD FOREIGN KEY account_sid_idxfk_9 (account_sid) REFERENCES accounts (account_sid);
|
||||
ALTER TABLE voip_carriers ADD FOREIGN KEY account_sid_idxfk_10 (account_sid) REFERENCES accounts (account_sid);
|
||||
|
||||
CREATE INDEX service_provider_sid_idx ON voip_carriers (service_provider_sid);
|
||||
ALTER TABLE voip_carriers ADD FOREIGN KEY service_provider_sid_idxfk_6 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
|
||||
ALTER TABLE voip_carriers ADD FOREIGN KEY service_provider_sid_idxfk_7 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
|
||||
|
||||
ALTER TABLE voip_carriers ADD FOREIGN KEY application_sid_idxfk_2 (application_sid) REFERENCES applications (application_sid);
|
||||
|
||||
CREATE INDEX user_permissions_sid_idx ON user_permissions (user_permissions_sid);
|
||||
CREATE INDEX user_sid_idx ON user_permissions (user_sid);
|
||||
ALTER TABLE user_permissions ADD FOREIGN KEY user_sid_idxfk (user_sid) REFERENCES users (user_sid) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE user_permissions ADD FOREIGN KEY permission_sid_idxfk (permission_sid) REFERENCES permissions (permission_sid);
|
||||
|
||||
CREATE INDEX smpp_gateway_sid_idx ON smpp_gateways (smpp_gateway_sid);
|
||||
CREATE INDEX voip_carrier_sid_idx ON smpp_gateways (voip_carrier_sid);
|
||||
ALTER TABLE smpp_gateways ADD FOREIGN KEY voip_carrier_sid_idxfk (voip_carrier_sid) REFERENCES voip_carriers (voip_carrier_sid);
|
||||
@@ -524,12 +598,12 @@ CREATE INDEX number_idx ON phone_numbers (number);
|
||||
CREATE INDEX voip_carrier_sid_idx ON phone_numbers (voip_carrier_sid);
|
||||
ALTER TABLE phone_numbers ADD FOREIGN KEY voip_carrier_sid_idxfk_1 (voip_carrier_sid) REFERENCES voip_carriers (voip_carrier_sid);
|
||||
|
||||
ALTER TABLE phone_numbers ADD FOREIGN KEY account_sid_idxfk_10 (account_sid) REFERENCES accounts (account_sid);
|
||||
ALTER TABLE phone_numbers ADD FOREIGN KEY account_sid_idxfk_11 (account_sid) REFERENCES accounts (account_sid);
|
||||
|
||||
ALTER TABLE phone_numbers ADD FOREIGN KEY application_sid_idxfk_3 (application_sid) REFERENCES applications (application_sid);
|
||||
|
||||
CREATE INDEX service_provider_sid_idx ON phone_numbers (service_provider_sid);
|
||||
ALTER TABLE phone_numbers ADD FOREIGN KEY service_provider_sid_idxfk_7 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
|
||||
ALTER TABLE phone_numbers ADD FOREIGN KEY service_provider_sid_idxfk_8 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
|
||||
|
||||
CREATE INDEX sip_gateway_idx_hostport ON sip_gateways (ipv4,port);
|
||||
|
||||
@@ -545,10 +619,10 @@ CREATE UNIQUE INDEX applications_idx_name ON applications (account_sid,name);
|
||||
|
||||
CREATE INDEX application_sid_idx ON applications (application_sid);
|
||||
CREATE INDEX service_provider_sid_idx ON applications (service_provider_sid);
|
||||
ALTER TABLE applications ADD FOREIGN KEY service_provider_sid_idxfk_8 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
|
||||
ALTER TABLE applications ADD FOREIGN KEY service_provider_sid_idxfk_9 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
|
||||
|
||||
CREATE INDEX account_sid_idx ON applications (account_sid);
|
||||
ALTER TABLE applications ADD FOREIGN KEY account_sid_idxfk_11 (account_sid) REFERENCES accounts (account_sid);
|
||||
ALTER TABLE applications ADD FOREIGN KEY account_sid_idxfk_12 (account_sid) REFERENCES accounts (account_sid);
|
||||
|
||||
ALTER TABLE applications ADD FOREIGN KEY call_hook_sid_idxfk (call_hook_sid) REFERENCES webhooks (webhook_sid);
|
||||
|
||||
@@ -564,7 +638,7 @@ ALTER TABLE service_providers ADD FOREIGN KEY registration_hook_sid_idxfk (regis
|
||||
CREATE INDEX account_sid_idx ON accounts (account_sid);
|
||||
CREATE INDEX sip_realm_idx ON accounts (sip_realm);
|
||||
CREATE INDEX service_provider_sid_idx ON accounts (service_provider_sid);
|
||||
ALTER TABLE accounts ADD FOREIGN KEY service_provider_sid_idxfk_9 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
|
||||
ALTER TABLE accounts ADD FOREIGN KEY service_provider_sid_idxfk_10 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
|
||||
|
||||
ALTER TABLE accounts ADD FOREIGN KEY registration_hook_sid_idxfk_1 (registration_hook_sid) REFERENCES webhooks (webhook_sid);
|
||||
|
||||
@@ -572,4 +646,6 @@ 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=0;
|
||||
ALTER TABLE accounts ADD FOREIGN KEY siprec_hook_sid_idxfk (siprec_hook_sid) REFERENCES applications (application_sid);
|
||||
|
||||
SET FOREIGN_KEY_CHECKS=1;
|
||||
214
test/dial-tests.js
Normal file
214
test/dial-tests.js
Normal file
@@ -0,0 +1,214 @@
|
||||
const test = require('tape');
|
||||
const { sippUac } = require('./sipp')('test_fs');
|
||||
const bent = require('bent');
|
||||
const getJSON = bent('json')
|
||||
const clearModule = require('clear-module');
|
||||
const {provisionCallHook} = require('./utils')
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
});
|
||||
|
||||
function connect(connectable) {
|
||||
return new Promise((resolve, reject) => {
|
||||
connectable.on('connect', () => {
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('\'dial-phone\'', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
try {
|
||||
await connect(srf);
|
||||
// wait for fs connected to drachtio server.
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// GIVEN
|
||||
const from = "dial_success";
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "dial",
|
||||
"callerId": from,
|
||||
"callerName": "test_callerName",
|
||||
"actionHook": "/actionHook",
|
||||
"timeLimit": 5,
|
||||
"target": [
|
||||
{
|
||||
"type": "phone",
|
||||
"number": "15083084809"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
provisionCallHook(from, verbs);
|
||||
|
||||
// THEN
|
||||
const p = sippUac('uas-dial.xml', '172.38.0.10', undefined, undefined, 2);
|
||||
|
||||
let account_sid = '622f62e4-303a-49f2-bbe0-eb1e1714e37a';
|
||||
let post = bent('http://127.0.0.1:3000/', 'POST', 'json', 201);
|
||||
post('v1/createCall', {
|
||||
'account_sid':account_sid,
|
||||
"call_hook": {
|
||||
"url": "http://127.0.0.1:3100/",
|
||||
"method": "POST",
|
||||
},
|
||||
"from": from,
|
||||
"callerName": "Tom",
|
||||
"to": {
|
||||
"type": "phone",
|
||||
"number": "15583084808"
|
||||
}});
|
||||
|
||||
await p;
|
||||
|
||||
obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
|
||||
t.ok(obj.body.from === from,
|
||||
'dial: succeeds actionHook');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('\'dial-sip\'', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
try {
|
||||
await connect(srf);
|
||||
// wait for fs connected to drachtio server.
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
// GIVEN
|
||||
const from = "dial_sip";
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "dial",
|
||||
"callerId": from,
|
||||
"actionHook": "/actionHook",
|
||||
"dtmfCapture":["*2", "*3"],
|
||||
"target": [
|
||||
{
|
||||
"type": "sip",
|
||||
"sipUri": "sip:15083084809@jambonz.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
provisionCallHook(from, verbs);
|
||||
|
||||
// THEN
|
||||
const p = sippUac('uas-dial.xml', '172.38.0.10', undefined, undefined, 2);
|
||||
|
||||
let account_sid = '622f62e4-303a-49f2-bbe0-eb1e1714e37a';
|
||||
|
||||
let post = bent('http://127.0.0.1:3000/', 'POST', 'json', 201);
|
||||
post('v1/createCall', {
|
||||
'account_sid':account_sid,
|
||||
"call_hook": {
|
||||
"url": "http://127.0.0.1:3100/",
|
||||
"method": "POST",
|
||||
},
|
||||
"from": from,
|
||||
"to": {
|
||||
"type": "phone",
|
||||
"number": "15583084808"
|
||||
}});
|
||||
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}`);
|
||||
const callSid = obj.body.call_sid;
|
||||
|
||||
post = bent('http://127.0.0.1:3000/', 'POST', 202);
|
||||
await post(`v1/updateCall/${callSid}`, {
|
||||
"call_status": "completed"
|
||||
});
|
||||
|
||||
await p;
|
||||
|
||||
obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
|
||||
t.ok(obj.body.from === from,
|
||||
'dial: succeeds actionHook');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'dial-user\'', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
try {
|
||||
await connect(srf);
|
||||
// wait for fs connected to drachtio server.
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
// GIVEN
|
||||
const from = "dial_user";
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "dial",
|
||||
"callerId": from,
|
||||
"actionHook": "/actionHook",
|
||||
"target": [
|
||||
{
|
||||
"type": "user",
|
||||
"name": "user110@jambonz.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
provisionCallHook(from, verbs);
|
||||
|
||||
// THEN
|
||||
const p = sippUac('uas-dial.xml', '172.38.0.10', undefined, undefined, 2);
|
||||
|
||||
let account_sid = '622f62e4-303a-49f2-bbe0-eb1e1714e37a';
|
||||
|
||||
let post = bent('http://127.0.0.1:3000/', 'POST', 'json', 201);
|
||||
post('v1/createCall', {
|
||||
'account_sid':account_sid,
|
||||
"call_hook": {
|
||||
"url": "http://127.0.0.1:3100/",
|
||||
"method": "POST",
|
||||
},
|
||||
"from": from,
|
||||
"to": {
|
||||
"type": "phone",
|
||||
"number": "15583084808"
|
||||
}});
|
||||
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}`);
|
||||
const callSid = obj.body.call_sid;
|
||||
|
||||
post = bent('http://127.0.0.1:3000/', 'POST', 202);
|
||||
await post(`v1/updateCall/${callSid}`, {
|
||||
"call_status": "completed"
|
||||
});
|
||||
|
||||
await p;
|
||||
|
||||
obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
|
||||
t.ok(obj.body.from === from,
|
||||
'dial: succeeds actionHook');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
platform: linux/x86_64
|
||||
ports:
|
||||
- "3360:3306"
|
||||
environment:
|
||||
environment:
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin" ,"ping", "-h", "127.0.0.1", "--protocol", "tcp"]
|
||||
@@ -44,7 +44,7 @@ services:
|
||||
drachtio:
|
||||
image: drachtio/drachtio-server:latest
|
||||
restart: always
|
||||
command: drachtio --contact "sip:*;transport=udp,tcp" --address 0.0.0.0 --port 9022
|
||||
command: drachtio --contact "sip:*;transport=udp" --mtu 4096 --address 0.0.0.0 --port 9022
|
||||
ports:
|
||||
- "9060:9022/tcp"
|
||||
networks:
|
||||
@@ -57,7 +57,7 @@ services:
|
||||
condition: service_healthy
|
||||
|
||||
freeswitch:
|
||||
image: drachtio/drachtio-freeswitch-mrf:v1.10.1-full
|
||||
image: drachtio/drachtio-freeswitch-mrf:0.4.18
|
||||
restart: always
|
||||
command: freeswitch --rtp-range-start 20000 --rtp-range-end 20100
|
||||
environment:
|
||||
@@ -68,17 +68,15 @@ services:
|
||||
- /tmp:/tmp
|
||||
- ./credentials:/opt/credentials
|
||||
healthcheck:
|
||||
test: ['CMD', 'fs_cli' ,'-x', '"sofia status"']
|
||||
test: ['CMD', 'fs_cli' ,'-p', 'JambonzR0ck$$', '-x', '"sofia status"']
|
||||
timeout: 5s
|
||||
retries: 15
|
||||
networks:
|
||||
fs:
|
||||
ipv4_address: 172.38.0.51
|
||||
|
||||
webhook-decline:
|
||||
webhook-scaffold:
|
||||
image: jambonz/webhook-test-scaffold:latest
|
||||
environment:
|
||||
APP_PATH: /tmp/decline.json
|
||||
ports:
|
||||
- "3100:3000/tcp"
|
||||
volumes:
|
||||
@@ -87,54 +85,6 @@ services:
|
||||
fs:
|
||||
ipv4_address: 172.38.0.60
|
||||
|
||||
webhook-say:
|
||||
image: jambonz/webhook-test-scaffold:latest
|
||||
environment:
|
||||
APP_PATH: /tmp/say.json
|
||||
ports:
|
||||
- "3101:3000/tcp"
|
||||
volumes:
|
||||
- ./test-apps:/tmp
|
||||
networks:
|
||||
fs:
|
||||
ipv4_address: 172.38.0.61
|
||||
|
||||
webhook-gather:
|
||||
image: jambonz/webhook-test-scaffold:latest
|
||||
environment:
|
||||
APP_PATH: /tmp/gather.json
|
||||
ports:
|
||||
- "3102:3000/tcp"
|
||||
volumes:
|
||||
- ./test-apps:/tmp
|
||||
networks:
|
||||
fs:
|
||||
ipv4_address: 172.38.0.62
|
||||
|
||||
webhook-transcribe:
|
||||
image: jambonz/webhook-test-scaffold:latest
|
||||
environment:
|
||||
APP_PATH: /tmp/transcribe.json
|
||||
ports:
|
||||
- "3103:3000/tcp"
|
||||
volumes:
|
||||
- ./test-apps:/tmp
|
||||
networks:
|
||||
fs:
|
||||
ipv4_address: 172.38.0.63
|
||||
|
||||
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
|
||||
ports:
|
||||
|
||||
@@ -3,6 +3,16 @@ const { sippUac } = require('./sipp')('test_fs');
|
||||
const bent = require('bent');
|
||||
const getJSON = bent('json')
|
||||
const clearModule = require('clear-module');
|
||||
const {provisionCallHook} = require('./utils')
|
||||
const {
|
||||
GCP_JSON_KEY,
|
||||
AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY,
|
||||
SONIOX_API_KEY,
|
||||
DEEPGRAM_API_KEY,
|
||||
MICROSOFT_REGION,
|
||||
MICROSOFT_API_KEY,
|
||||
} = require('../lib/config');
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
@@ -16,16 +26,285 @@ function connect(connectable) {
|
||||
});
|
||||
}
|
||||
|
||||
test('\'gather\' and \'transcribe\' tests', async(t) => {
|
||||
test('\'gather\' test - google', async(t) => {
|
||||
if (!GCP_JSON_KEY) {
|
||||
t.pass('skipping google tests');
|
||||
return t.end();
|
||||
}
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10');
|
||||
let obj = await getJSON('http://127.0.0.1:3102/actionHook');
|
||||
t.ok(obj.speech.alternatives[0].transcript = 'I\'d like to speak to customer support',
|
||||
'gather: succeeds when using account credentials');
|
||||
// GIVEN
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "gather",
|
||||
"input": ["speech"],
|
||||
"recognizer": {
|
||||
"vendor": "google",
|
||||
"hints": ["customer support", "sales", "human resources", "HR"]
|
||||
},
|
||||
"timeout": 10,
|
||||
"actionHook": "/actionHook"
|
||||
}
|
||||
];
|
||||
let from = "gather_success";
|
||||
provisionCallHook(from, verbs);
|
||||
// THEN
|
||||
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
|
||||
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
|
||||
//console.log(JSON.stringify(obj));
|
||||
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
|
||||
'gather: succeeds when using google credentials');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'gather\' test - default (google)', async(t) => {
|
||||
if (!GCP_JSON_KEY) {
|
||||
t.pass('skipping google tests');
|
||||
return t.end();
|
||||
}
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
// GIVEN
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "gather",
|
||||
"input": ["speech"],
|
||||
"timeout": 10,
|
||||
"actionHook": "/actionHook"
|
||||
}
|
||||
];
|
||||
let from = "gather_success";
|
||||
provisionCallHook(from, verbs);
|
||||
// THEN
|
||||
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
|
||||
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
|
||||
//console.log(JSON.stringify(obj));
|
||||
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase() === 'i\'d like to speak to customer support',
|
||||
'gather: succeeds when using default (google) credentials');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'config\' test - reset to app defaults', async(t) => {
|
||||
if (!GCP_JSON_KEY) {
|
||||
t.pass('skipping config tests');
|
||||
return t.end();
|
||||
}
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
// GIVEN
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "config",
|
||||
"recognizer": {
|
||||
"vendor": "google",
|
||||
"language": "fr-FR"
|
||||
},
|
||||
},
|
||||
{
|
||||
"verb": "config",
|
||||
"reset": ['recognizer'],
|
||||
},
|
||||
{
|
||||
"verb": "gather",
|
||||
"input": ["speech"],
|
||||
"timeout": 10,
|
||||
"actionHook": "/actionHook"
|
||||
}
|
||||
];
|
||||
let from = "gather_success";
|
||||
provisionCallHook(from, verbs);
|
||||
// THEN
|
||||
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
|
||||
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
|
||||
//console.log(JSON.stringify(obj));
|
||||
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase() === 'i\'d like to speak to customer support',
|
||||
'config: resets recognizer to app defaults');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'gather\' test - microsoft', async(t) => {
|
||||
if (!MICROSOFT_REGION || !MICROSOFT_API_KEY) {
|
||||
t.pass('skipping microsoft tests');
|
||||
return t.end();
|
||||
}
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
// GIVEN
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "gather",
|
||||
"input": ["speech"],
|
||||
"recognizer": {
|
||||
"vendor": "microsoft",
|
||||
"hints": ["customer support", "sales", "human resources", "HR"]
|
||||
},
|
||||
"timeout": 10,
|
||||
"actionHook": "/actionHook"
|
||||
}
|
||||
];
|
||||
let from = "gather_success";
|
||||
provisionCallHook(from, verbs);
|
||||
// THEN
|
||||
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
|
||||
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
|
||||
//console.log(JSON.stringify(obj));
|
||||
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
|
||||
'gather: succeeds when using microsoft credentials');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'gather\' test - aws', async(t) => {
|
||||
if (!AWS_ACCESS_KEY_ID || !AWS_SECRET_ACCESS_KEY) {
|
||||
t.pass('skipping aws tests');
|
||||
return t.end();
|
||||
}
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
// GIVEN
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "gather",
|
||||
"input": ["speech"],
|
||||
"recognizer": {
|
||||
"vendor": "aws",
|
||||
"hints": ["customer support", "sales", "human resources", "HR"]
|
||||
},
|
||||
"timeout": 10,
|
||||
"actionHook": "/actionHook"
|
||||
}
|
||||
];
|
||||
let from = "gather_success";
|
||||
provisionCallHook(from, verbs);
|
||||
// THEN
|
||||
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
|
||||
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
|
||||
//console.log(JSON.stringify(obj));
|
||||
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
|
||||
'gather: succeeds when using aws credentials');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'gather\' test - deepgram', async(t) => {
|
||||
if (!DEEPGRAM_API_KEY ) {
|
||||
t.pass('skipping deepgram tests');
|
||||
return t.end();
|
||||
}
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
// GIVEN
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "gather",
|
||||
"input": ["speech"],
|
||||
"recognizer": {
|
||||
"vendor": "deepgram",
|
||||
"hints": ["customer support", "sales", "human resources", "HR"],
|
||||
"deepgramOptions": {
|
||||
"apiKey": DEEPGRAM_API_KEY
|
||||
}
|
||||
},
|
||||
"timeout": 10,
|
||||
"actionHook": "/actionHook"
|
||||
}
|
||||
];
|
||||
let from = "gather_success";
|
||||
provisionCallHook(from, verbs);
|
||||
// THEN
|
||||
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
|
||||
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
|
||||
//console.log(JSON.stringify(obj));
|
||||
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().includes('like to speak to customer support'),
|
||||
'gather: succeeds when using deepgram credentials');
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'gather\' test - soniox', async(t) => {
|
||||
if (!SONIOX_API_KEY ) {
|
||||
t.pass('skipping soniox tests');
|
||||
return t.end();
|
||||
}
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
// GIVEN
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "gather",
|
||||
"input": ["speech"],
|
||||
"recognizer": {
|
||||
"vendor": "deepgram",
|
||||
"hints": ["customer support", "sales", "human resources", "HR"],
|
||||
"deepgramOptions": {
|
||||
"apiKey": SONIOX_API_KEY
|
||||
}
|
||||
},
|
||||
"timeout": 10,
|
||||
"actionHook": "/actionHook"
|
||||
}
|
||||
];
|
||||
let from = "gather_success";
|
||||
provisionCallHook(from, verbs);
|
||||
// THEN
|
||||
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
|
||||
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
|
||||
console.log(JSON.stringify(obj));
|
||||
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
|
||||
'gather: succeeds when using soniox credentials');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
require('./ws-requestor-unit-test');
|
||||
require('./unit-tests');
|
||||
require('./docker_start');
|
||||
require('./create-test-db');
|
||||
require('./account-validation-tests');
|
||||
require('./dial-tests');
|
||||
require('./webhooks-tests');
|
||||
require('./say-tests');
|
||||
require('./gather-tests');
|
||||
require('./transcribe-tests');
|
||||
require('./sip-request-tests');
|
||||
require('./create-call-test');
|
||||
require('./play-tests');
|
||||
require('./sip-refer-tests');
|
||||
require('./listen-tests');
|
||||
require('./config-test');
|
||||
require('./remove-test-db');
|
||||
require('./docker_stop');
|
||||
require('./docker_stop');
|
||||
149
test/listen-tests.js
Normal file
149
test/listen-tests.js
Normal file
@@ -0,0 +1,149 @@
|
||||
const test = require('tape');
|
||||
const { sippUac } = require('./sipp')('test_fs');
|
||||
const bent = require('bent');
|
||||
const getJSON = bent('json')
|
||||
const clearModule = require('clear-module');
|
||||
const {provisionCallHook} = require('./utils')
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
});
|
||||
|
||||
function connect(connectable) {
|
||||
return new Promise((resolve, reject) => {
|
||||
connectable.on('connect', () => {
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('\'listen-success\'', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
// GIVEN
|
||||
const from = "listen_success";
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "listen",
|
||||
"url": `ws://172.38.0.60:3000/${from}`,
|
||||
"mixType" : "mono",
|
||||
"actionHook": "/actionHook",
|
||||
"playBeep": true,
|
||||
}
|
||||
];
|
||||
|
||||
provisionCallHook(from, verbs);
|
||||
|
||||
// THEN
|
||||
await sippUac('uac-gather-account-creds-success-send-bye.xml', '172.38.0.10', from);
|
||||
let obj = await getJSON(`http://127.0.0.1:3100/ws_packet_count/${from}`);
|
||||
t.ok(38000 <= obj.count, 'listen: success incoming call audio');
|
||||
|
||||
obj = await getJSON(`http://127.0.0.1:3100/ws_metadata/${from}`);
|
||||
t.ok(obj.metadata.from === from && obj.metadata.sampleRate === 8000, 'listen: success metadata');
|
||||
|
||||
obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
|
||||
t.ok(obj.body.from === from,
|
||||
'listen: succeeds actionHook');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'listen-maxLength\'', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
// GIVEN
|
||||
let from = "listen_timeout";
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "listen",
|
||||
"url": `ws://172.38.0.60:3000/${from}`,
|
||||
"mixType" : "mixed",
|
||||
"timeout": 2,
|
||||
"maxLength": 2
|
||||
}
|
||||
];
|
||||
|
||||
provisionCallHook(from, verbs);
|
||||
|
||||
// THEN
|
||||
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
|
||||
let obj = await getJSON(`http://127.0.0.1:3100/ws_packet_count/${from}`);
|
||||
t.ok(30000 <= obj.count, 'listen: success maxLength incoming call audio');
|
||||
|
||||
obj = await getJSON(`http://127.0.0.1:3100/ws_metadata/${from}`);
|
||||
t.ok(obj.metadata.from === from && obj.metadata.sampleRate === 8000, 'listen: success maxLength metadata');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'listen-pause-resume\'', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
// GIVEN
|
||||
let from = "listen_timeout";
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "listen",
|
||||
"url": `ws://172.38.0.60:3000/${from}`,
|
||||
"mixType" : "mixed"
|
||||
}
|
||||
];
|
||||
|
||||
provisionCallHook(from, verbs);
|
||||
|
||||
// THEN
|
||||
const p = sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}`);
|
||||
const callSid = obj.body.call_sid;
|
||||
|
||||
// GIVEN
|
||||
// Pause listen
|
||||
let post = bent('http://127.0.0.1:3000/', 'POST', 202);
|
||||
await post(`v1/updateCall/${callSid}`, {
|
||||
"listen_status": "pause"
|
||||
});
|
||||
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
// Resume listen
|
||||
post = bent('http://127.0.0.1:3000/', 'POST', 202);
|
||||
await post(`v1/updateCall/${callSid}`, {
|
||||
"listen_status": "resume"
|
||||
});
|
||||
|
||||
// turn off the call
|
||||
post = bent('http://127.0.0.1:3000/', 'POST', 202);
|
||||
await post(`v1/updateCall/${callSid}`, {
|
||||
"call_status": "completed"
|
||||
});
|
||||
|
||||
await p;
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
254
test/play-tests.js
Normal file
254
test/play-tests.js
Normal file
@@ -0,0 +1,254 @@
|
||||
const test = require('tape');
|
||||
const { sippUac } = require('./sipp')('test_fs');
|
||||
const clearModule = require('clear-module');
|
||||
const {provisionCallHook, provisionCustomHook} = require('./utils')
|
||||
const bent = require('bent');
|
||||
const getJSON = bent('json')
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
});
|
||||
|
||||
function connect(connectable) {
|
||||
return new Promise((resolve, reject) => {
|
||||
connectable.on('connect', () => {
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('\'play\' tests single link in plain text', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
// GIVEN
|
||||
const verbs = [
|
||||
{
|
||||
verb: 'play',
|
||||
url: 'https://example.com/example.mp3'
|
||||
}
|
||||
];
|
||||
|
||||
const from = 'play_single_link';
|
||||
provisionCallHook(from, verbs)
|
||||
|
||||
// THEN
|
||||
await sippUac('uac-success-received-bye.xml', '172.38.0.10', from);
|
||||
t.pass('play: succeeds when using single link');
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'play\' tests multi links in array', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
// GIVEN
|
||||
const verbs = [
|
||||
{
|
||||
verb: 'play',
|
||||
url: ['https://example.com/example.mp3', 'https://example.com/example.mp3']
|
||||
}
|
||||
];
|
||||
|
||||
const from = 'play_multi_links_in_array';
|
||||
provisionCallHook(from, verbs)
|
||||
|
||||
// THEN
|
||||
await sippUac('uac-success-received-bye.xml', '172.38.0.10', from);
|
||||
t.pass('play: succeeds when using links in array');
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'play\' tests single link in conference', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
// GIVEN
|
||||
const from = 'play_single_link_in_conference';
|
||||
const waitHookVerbs = [
|
||||
{
|
||||
verb: 'play',
|
||||
url: 'https://example.com/example.mp3'
|
||||
}
|
||||
];
|
||||
|
||||
const verbs = [
|
||||
{
|
||||
verb: 'conference',
|
||||
name: `${from}`,
|
||||
beep: true,
|
||||
"startConferenceOnEnter": false,
|
||||
waitHook: `/customHook`
|
||||
}
|
||||
];
|
||||
provisionCustomHook(from, waitHookVerbs)
|
||||
provisionCallHook(from, verbs)
|
||||
|
||||
// THEN
|
||||
await sippUac('uac-success-send-bye.xml', '172.38.0.10', from);
|
||||
t.pass('play: succeeds when using in conference as single link');
|
||||
// Make sure that waitHook is called and success
|
||||
await getJSON(`http:127.0.0.1:3100/lastRequest/${from}_customHook`)
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'play\' tests multi links in array in conference', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
// GIVEN
|
||||
const from = 'play_multi_links_in_conference';
|
||||
const waitHookVerbs = [
|
||||
{
|
||||
verb: 'play',
|
||||
url: ['https://example.com/example.mp3', 'https://example.com/example.mp3']
|
||||
}
|
||||
];
|
||||
|
||||
const verbs = [
|
||||
{
|
||||
verb: 'conference',
|
||||
name: `${from}`,
|
||||
beep: true,
|
||||
"startConferenceOnEnter": false,
|
||||
waitHook: `/customHook`
|
||||
}
|
||||
];
|
||||
provisionCustomHook(from, waitHookVerbs)
|
||||
provisionCallHook(from, verbs)
|
||||
|
||||
// THEN
|
||||
await sippUac('uac-success-send-bye.xml', '172.38.0.10', from);
|
||||
t.pass('play: succeeds when using in conference with multi links');
|
||||
// Make sure that waitHook is called and success
|
||||
await getJSON(`http:127.0.0.1:3100/lastRequest/${from}_customHook`)
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'play\' tests with seekOffset and actionHook', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
// GIVEN
|
||||
const verbs = [
|
||||
{
|
||||
verb: 'play',
|
||||
url: 'silence_stream://5000',
|
||||
seekOffset: 8000,
|
||||
timeoutSecs: 2,
|
||||
actionHook: '/customHook'
|
||||
}
|
||||
];
|
||||
|
||||
const waitHookVerbs = [];
|
||||
|
||||
const from = 'play_action_hook';
|
||||
provisionCallHook(from, verbs)
|
||||
provisionCustomHook(from, waitHookVerbs)
|
||||
|
||||
// THEN
|
||||
await sippUac('uac-success-received-bye.xml', '172.38.0.10', from);
|
||||
t.pass('play: succeeds');
|
||||
const obj = await getJSON(`http:127.0.0.1:3100/lastRequest/${from}_customHook`);
|
||||
const seconds = parseInt(obj.body.playback_seconds);
|
||||
const milliseconds = parseInt(obj.body.playback_milliseconds);
|
||||
const lastOffsetPos = parseInt(obj.body.playback_last_offset_pos);
|
||||
//console.log({obj}, 'lastRequest');
|
||||
t.ok(obj.body.reason === "playCompleted", "play: actionHook success received");
|
||||
t.ok(seconds === 2, "playback_seconds: actionHook success received");
|
||||
t.ok(milliseconds === 2048, "playback_milliseconds: actionHook success received");
|
||||
t.ok(lastOffsetPos > 15500 && lastOffsetPos < 16500, "playback_last_offset_pos: actionHook success received")
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'play\' tests with earlymedia', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
// GIVEN
|
||||
const verbs = [
|
||||
{
|
||||
verb: 'play',
|
||||
url: 'silence_stream://5000',
|
||||
earlyMedia: true
|
||||
}
|
||||
];
|
||||
|
||||
const from = 'play_early_media';
|
||||
provisionCallHook(from, verbs)
|
||||
|
||||
// THEN
|
||||
await sippUac('uac-invite-expect-183-cancel.xml', '172.38.0.10', from);
|
||||
const obj = await getJSON(`http:127.0.0.1:3100/lastRequest/${from}_callStatus`);
|
||||
t.ok(obj.body.sip_status === 487, "play: actionHook success received");
|
||||
t.ok(obj.body.sip_reason === 'Request Terminated', "play: actionHook success received");
|
||||
t.ok(obj.body.call_termination_by === 'caller', "play: actionHook success received");
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'play\' tests with initial app_json', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
const from = 'play_initial_app_json';
|
||||
|
||||
// THEN
|
||||
await sippUac('uac-success-received-bye.xml', '172.38.0.10', from, "16174000007");
|
||||
t.pass('application can use app_json for initial instructions');
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
@@ -5,7 +5,6 @@ test('dropping jambones_test database', (t) => {
|
||||
exec(`mysql -h 127.0.0.1 -u root --protocol=tcp --port=3360 < ${__dirname}/db/remove_test_db.sql`, (err, stdout, stderr) => {
|
||||
if (err) return t.end(err);
|
||||
t.pass('database successfully dropped');
|
||||
fs.unlinkSync(`${__dirname}/credentials/gcp.json`);
|
||||
t.end();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const test = require('tape');
|
||||
const { sippUac } = require('./sipp')('test_fs');
|
||||
const clearModule = require('clear-module');
|
||||
const {provisionCallHook} = require('./utils')
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
@@ -20,9 +21,21 @@ test('\'say\' tests', async(t) => {
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
await sippUac('uac-say-account-creds-success.xml', '172.38.0.10');
|
||||
|
||||
// GIVEN
|
||||
const verbs = [
|
||||
{
|
||||
verb: 'say',
|
||||
text: 'hello'
|
||||
}
|
||||
];
|
||||
|
||||
const from = 'say_test_success';
|
||||
provisionCallHook(from, verbs)
|
||||
|
||||
// THEN
|
||||
await sippUac('uac-success-received-bye.xml', '172.38.0.10', from);
|
||||
t.pass('say: succeeds when using using account credentials');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
@@ -30,3 +43,84 @@ test('\'say\' tests', async(t) => {
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'config\' reset synthesizer tests', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
// GIVEN
|
||||
const verbs = [
|
||||
{
|
||||
"verb": "config",
|
||||
"synthesizer": {
|
||||
"vendor": "microsft",
|
||||
"voice": "foobar"
|
||||
},
|
||||
},
|
||||
{
|
||||
"verb": "config",
|
||||
"reset": 'synthesizer',
|
||||
},
|
||||
{
|
||||
verb: 'say',
|
||||
text: 'hello'
|
||||
}
|
||||
];
|
||||
|
||||
const from = 'say_test_success';
|
||||
provisionCallHook(from, verbs)
|
||||
|
||||
// THEN
|
||||
await sippUac('uac-success-received-bye.xml', '172.38.0.10', from);
|
||||
t.pass('say: succeeds when using using account credentials');
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
const {MICROSOFT_CUSTOM_API_KEY, MICROSOFT_DEPLOYMENT_ID, MICROSOFT_CUSTOM_REGION, MICROSOFT_CUSTOM_VOICE} = process.env;
|
||||
if (MICROSOFT_CUSTOM_API_KEY && MICROSOFT_DEPLOYMENT_ID && MICROSOFT_CUSTOM_REGION && MICROSOFT_CUSTOM_VOICE) {
|
||||
test('\'say\' tests - microsoft custom voice', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
// GIVEN
|
||||
const verbs = [
|
||||
{
|
||||
verb: 'say',
|
||||
text: 'hello',
|
||||
synthesizer: {
|
||||
vendor: 'microsoft',
|
||||
voice: MICROSOFT_CUSTOM_VOICE,
|
||||
options: {
|
||||
deploymentId: MICROSOFT_DEPLOYMENT_ID,
|
||||
apiKey: MICROSOFT_CUSTOM_API_KEY,
|
||||
region: MICROSOFT_CUSTOM_REGION,
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const from = 'say_test_success';
|
||||
provisionCallHook(from, verbs)
|
||||
|
||||
// THEN
|
||||
await sippUac('uac-success-received-bye.xml', '172.38.0.10', from);
|
||||
t.pass('say: succeeds when using microsoft custom voice');
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
INVITE sip:16174000000@[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]
|
||||
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: <sip:16174000000@[remote_ip]:[remote_port]>
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 INVITE
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
ACK sip:16174000000@[remote_ip]:[remote_port] SIP/2.0
|
||||
[last_Via]
|
||||
From: sipp <sip:sipp@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: <sip:16174000000@[remote_ip]:[remote_port]>[peer_tag_param]
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 ACK
|
||||
@@ -53,4 +53,3 @@
|
||||
</send>
|
||||
|
||||
</scenario>
|
||||
|
||||
|
||||
99
test/scenarios/uac-gather-account-creds-success-send-bye.xml
Normal file
99
test/scenarios/uac-gather-account-creds-success-send-bye.xml
Normal file
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="ISO-8859-1" ?>
|
||||
<!DOCTYPE scenario SYSTEM "sipp.dtd">
|
||||
|
||||
|
||||
<scenario name="Basic Sipstone UAC">
|
||||
<!-- In client mode (sipp placing calls), the Call-ID MUST be -->
|
||||
<!-- generated by sipp. To do so, use [call_id] keyword. -->
|
||||
<send retrans="500">
|
||||
<![CDATA[
|
||||
|
||||
INVITE sip:[to]@[remote_ip]:[remote_port] SIP/2.0
|
||||
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
||||
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: <sip:[to]@[remote_ip]:[remote_port]>
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 INVITE
|
||||
Contact: sip:[from]@[local_ip]:[local_port]
|
||||
Max-Forwards: 70
|
||||
X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f
|
||||
Subject: uac-gather-account-creds-success
|
||||
Content-Type: application/sdp
|
||||
Content-Length: [len]
|
||||
|
||||
v=0
|
||||
o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip]
|
||||
s=-
|
||||
c=IN IP[media_ip_type] [media_ip]
|
||||
t=0 0
|
||||
m=audio [media_port] RTP/AVP 0
|
||||
a=rtpmap:0 PCMU/8000
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<recv response="100"
|
||||
optional="true">
|
||||
</recv>
|
||||
|
||||
<recv response="180" optional="true">
|
||||
</recv>
|
||||
|
||||
<recv response="183" optional="true">
|
||||
</recv>
|
||||
|
||||
<!-- By adding rrs="true" (Record Route Sets), the route sets -->
|
||||
<!-- are saved and used for following messages sent. Useful to test -->
|
||||
<!-- against stateful SIP proxies/B2BUAs. -->
|
||||
<recv response="200" rtd="true">
|
||||
</recv>
|
||||
|
||||
<!-- Packet lost can be simulated in any send/recv message by -->
|
||||
<!-- by adding the 'lost = "10"'. Value can be [1-100] percent. -->
|
||||
<send>
|
||||
<![CDATA[
|
||||
|
||||
ACK sip:[to]@[remote_ip]:[remote_port] SIP/2.0
|
||||
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
||||
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: [to] <sip:[to]@[remote_ip]:[remote_port]>[peer_tag_param]
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 ACK
|
||||
Contact: sip:[from]@[local_ip]:[local_port]
|
||||
Max-Forwards: 70
|
||||
Subject: uac-gather-account-creds-success
|
||||
Content-Length: 0
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<nop>
|
||||
<action>
|
||||
<exec rtp_stream="/tmp/scenarios/wav/speak-to-customer-support.wav,1,0"/>
|
||||
</action>
|
||||
</nop>
|
||||
|
||||
<!-- Pause briefly -->
|
||||
<pause milliseconds="3000"/>
|
||||
|
||||
<!-- The 'crlf' option inserts a blank line in the statistics report. -->
|
||||
<send retrans="500">
|
||||
<![CDATA[
|
||||
|
||||
BYE sip:[to]@[remote_ip]:[remote_port] SIP/2.0
|
||||
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
||||
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: [to] <sip:[to]@[remote_ip]:[remote_port]>[peer_tag_param]
|
||||
Call-ID: [call_id]
|
||||
CSeq: 3 BYE
|
||||
Contact: sip:[from]@[local_ip]:[local_port]
|
||||
Max-Forwards: 70
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<recv response="200" crlf="true">
|
||||
</recv>
|
||||
|
||||
</scenario>
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
<send retrans="500">
|
||||
<![CDATA[
|
||||
|
||||
INVITE sip:16174000003@[remote_ip]:[remote_port] SIP/2.0
|
||||
INVITE sip:[to]@[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:16174000003@[remote_ip]:[remote_port]>
|
||||
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: <sip:[to]@[remote_ip]:[remote_port]>
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 INVITE
|
||||
Contact: sip:sipp@[local_ip]:[local_port]
|
||||
Contact: sip:[from]@[local_ip]:[local_port]
|
||||
Max-Forwards: 70
|
||||
X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f
|
||||
Subject: uac-gather-account-creds-success
|
||||
@@ -53,13 +53,13 @@
|
||||
<send>
|
||||
<![CDATA[
|
||||
|
||||
ACK sip:16174000003@[remote_ip]:[remote_port] SIP/2.0
|
||||
ACK sip:[to]@[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: 16174000003 <sip:16174000003@[remote_ip]:[remote_port]>[peer_tag_param]
|
||||
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: [to] <sip:[to]@[remote_ip]:[remote_port]>[peer_tag_param]
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 ACK
|
||||
Contact: sip:sipp@[local_ip]:[local_port]
|
||||
Contact: sip:[from]@[local_ip]:[local_port]
|
||||
Max-Forwards: 70
|
||||
Subject: uac-gather-account-creds-success
|
||||
Content-Length: 0
|
||||
|
||||
86
test/scenarios/uac-invite-expect-183-cancel.xml
Normal file
86
test/scenarios/uac-invite-expect-183-cancel.xml
Normal file
@@ -0,0 +1,86 @@
|
||||
<?xml version="1.0" encoding="ISO-8859-1" ?>
|
||||
<!DOCTYPE scenario SYSTEM "sipp.dtd">
|
||||
|
||||
|
||||
<scenario name="Basic Sipstone UAC">
|
||||
<!-- In client mode (sipp placing calls), the Call-ID MUST be -->
|
||||
<!-- generated by sipp. To do so, use [call_id] keyword. -->
|
||||
<send retrans="500">
|
||||
<![CDATA[
|
||||
|
||||
INVITE sip:[to]@[remote_ip]:[remote_port] SIP/2.0
|
||||
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
||||
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: <sip:[to]@[remote_ip]:[remote_port]>
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 INVITE
|
||||
Contact: sip:[from]@[local_ip]:[local_port]
|
||||
Max-Forwards: 70
|
||||
X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f
|
||||
Subject: uac-say
|
||||
Content-Type: application/sdp
|
||||
Content-Length: [len]
|
||||
|
||||
v=0
|
||||
o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip]
|
||||
s=-
|
||||
c=IN IP[media_ip_type] [media_ip]
|
||||
t=0 0
|
||||
m=audio [media_port] RTP/AVP 0
|
||||
a=rtpmap:0 PCMU/8000
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<recv response="100"
|
||||
optional="true">
|
||||
</recv>
|
||||
|
||||
<recv response="180" optional="true">
|
||||
</recv>
|
||||
|
||||
<recv response="183" rtd="true">
|
||||
<action>
|
||||
<ereg regexp=";branch=[^;]*" search_in="hdr" header="Via" check_it="false" assign_to="1"/>
|
||||
</action>
|
||||
</recv>
|
||||
|
||||
<send>
|
||||
<![CDATA[
|
||||
CANCEL sip:[to]@[remote_ip]:[remote_port] SIP/2.0
|
||||
Via: SIP/2.0/[transport] [local_ip]:[local_port][$1]
|
||||
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: [to] <sip:[to]@[remote_ip]:[remote_port]>
|
||||
Call-ID: [call_id]
|
||||
CSeq: [cseq] CANCEL
|
||||
Contact: sip:[from]@[local_ip]:[local_port]
|
||||
Max-Forwards: 70
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<recv response="200" rtd="true">
|
||||
</recv>
|
||||
|
||||
<recv response="487" rtd="true">
|
||||
</recv>
|
||||
|
||||
<!-- Packet lost can be simulated in any send/recv message by -->
|
||||
<!-- by adding the 'lost = "10"'. Value can be [1-100] percent. -->
|
||||
<send>
|
||||
<![CDATA[
|
||||
|
||||
ACK sip:[to]@[remote_ip]:[remote_port] SIP/2.0
|
||||
Via: SIP/2.0/[transport] [local_ip]:[local_port][$1]
|
||||
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: [to] <sip:[to]@[remote_ip]:[remote_port]>[peer_tag_param]
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 ACK
|
||||
Contact: sip:[from]@[local_ip]:[local_port]
|
||||
Max-Forwards: 70
|
||||
Subject: uac-say
|
||||
Content-Length: 0
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
</scenario>
|
||||
71
test/scenarios/uac-invite-expect-480.xml
Normal file
71
test/scenarios/uac-invite-expect-480.xml
Normal file
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="ISO-8859-1" ?>
|
||||
<!DOCTYPE scenario SYSTEM "sipp.dtd">
|
||||
|
||||
|
||||
<scenario name="Basic Sipstone UAC">
|
||||
<!-- In client mode (sipp placing calls), the Call-ID MUST be -->
|
||||
<!-- generated by sipp. To do so, use [call_id] keyword. -->
|
||||
<send retrans="500">
|
||||
<![CDATA[
|
||||
|
||||
INVITE sip:[to]@[remote_ip]:[remote_port] SIP/2.0
|
||||
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
||||
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: <sip:[to]@[remote_ip]:[remote_port]>
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 INVITE
|
||||
Contact: sip:[from]@[local_ip]:[local_port]
|
||||
Max-Forwards: 70
|
||||
X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f
|
||||
Subject: uac-say
|
||||
Content-Type: application/sdp
|
||||
Content-Length: [len]
|
||||
|
||||
v=0
|
||||
o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip]
|
||||
s=-
|
||||
c=IN IP[media_ip_type] [media_ip]
|
||||
t=0 0
|
||||
m=audio [media_port] RTP/AVP 0
|
||||
a=rtpmap:0 PCMU/8000
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<recv response="100"
|
||||
optional="true">
|
||||
</recv>
|
||||
|
||||
<recv response="180" optional="true">
|
||||
</recv>
|
||||
|
||||
<recv response="183" optional="true">
|
||||
</recv>
|
||||
|
||||
<!-- By adding rrs="true" (Record Route Sets), the route sets -->
|
||||
<!-- are saved and used for following messages sent. Useful to test -->
|
||||
<!-- against stateful SIP proxies/B2BUAs. -->
|
||||
<recv response="480" rtd="true">
|
||||
</recv>
|
||||
|
||||
<!-- Packet lost can be simulated in any send/recv message by -->
|
||||
<!-- by adding the 'lost = "10"'. Value can be [1-100] percent. -->
|
||||
<send>
|
||||
<![CDATA[
|
||||
|
||||
ACK sip:[to]@[remote_ip]:[remote_port] SIP/2.0
|
||||
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
||||
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: [to] <sip:[to]@[remote_ip]:[remote_port]>[peer_tag_param]
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 ACK
|
||||
Contact: sip:[from]@[local_ip]:[local_port]
|
||||
Max-Forwards: 70
|
||||
Subject: uac-say
|
||||
Content-Length: 0
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
</scenario>
|
||||
|
||||
95
test/scenarios/uac-refer-no-notify.xml
Normal file
95
test/scenarios/uac-refer-no-notify.xml
Normal file
@@ -0,0 +1,95 @@
|
||||
<?xml version="1.0" encoding="ISO-8859-1" ?>
|
||||
<!DOCTYPE scenario SYSTEM "sipp.dtd">
|
||||
|
||||
<scenario name="Basic Sipstone UAC">
|
||||
<!-- In client mode (sipp placing calls), the Call-ID MUST be -->
|
||||
<!-- generated by sipp. To do so, use [call_id] keyword. -->
|
||||
<send retrans="500">
|
||||
<![CDATA[
|
||||
|
||||
INVITE sip:[to]@[remote_ip]:[remote_port] SIP/2.0
|
||||
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
||||
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: <sip:[to]@[remote_ip]:[remote_port]>
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 INVITE
|
||||
Contact: sip:[from]@[local_ip]:[local_port]
|
||||
Max-Forwards: 70
|
||||
X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f
|
||||
Subject: uac-refer-no-notify.xml
|
||||
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:[service]@[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: [service] <sip:[service]@[remote_ip]:[remote_port]>[peer_tag_param]
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 ACK
|
||||
Max-Forwards: 70
|
||||
Subject: REFER test with no NOT
|
||||
Content-Length: 0
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<!-- receive re-invite -->
|
||||
<recv request="REFER" crlf="true"/>
|
||||
|
||||
<send>
|
||||
<![CDATA[
|
||||
|
||||
SIP/2.0 202 Accepted
|
||||
[last_Via:]
|
||||
[last_From:]
|
||||
[last_To:]
|
||||
[last_Call-ID:]
|
||||
[last_CSeq:]
|
||||
Contact: sip:sipp@[local_ip]:[local_port]
|
||||
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>
|
||||
|
||||
</scenario>
|
||||
115
test/scenarios/uac-refer-with-notify.xml
Normal file
115
test/scenarios/uac-refer-with-notify.xml
Normal file
@@ -0,0 +1,115 @@
|
||||
<?xml version="1.0" encoding="ISO-8859-1" ?>
|
||||
<!DOCTYPE scenario SYSTEM "sipp.dtd">
|
||||
|
||||
<scenario name="Basic Sipstone UAC">
|
||||
<!-- In client mode (sipp placing calls), the Call-ID MUST be -->
|
||||
<!-- generated by sipp. To do so, use [call_id] keyword. -->
|
||||
<send retrans="500">
|
||||
<![CDATA[
|
||||
|
||||
INVITE sip:[to]@[remote_ip]:[remote_port] SIP/2.0
|
||||
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
||||
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: <sip:16174000000@[remote_ip]:[remote_port]>
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 INVITE
|
||||
Contact: sip:[from]@[local_ip]:[local_port]
|
||||
Max-Forwards: 70
|
||||
X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f
|
||||
Subject: uac-refer-with-notify.xml
|
||||
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:[service]@[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:16174000000@[remote_ip]:[remote_port]>[peer_tag_param]
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 ACK
|
||||
Max-Forwards: 70
|
||||
Subject: uac-refer-with-notify.xml
|
||||
Content-Length: 0
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<!-- receive re-invite -->
|
||||
<recv request="REFER" crlf="true"/>
|
||||
|
||||
<send>
|
||||
<![CDATA[
|
||||
|
||||
SIP/2.0 202 Accepted
|
||||
[last_Via:]
|
||||
[last_From:]
|
||||
[last_To:]
|
||||
[last_Call-ID:]
|
||||
[last_CSeq:]
|
||||
Contact: sip:sipp@[local_ip]:[local_port]
|
||||
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>
|
||||
|
||||
<send retrans="500">
|
||||
<![CDATA[
|
||||
|
||||
NOTIFY sip:[service]@[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:16174000000@[remote_ip]:[remote_port]>[peer_tag_param]
|
||||
Call-ID: [call_id]
|
||||
CSeq: 2 NOTIFY
|
||||
Contact: sip:sipp@[local_ip]:[local_port]
|
||||
Max-Forwards: 70
|
||||
Subject: uac-refer-with-notify.xml
|
||||
Content-Type: message/sipfrag;version=2.0
|
||||
Content-Length: 16
|
||||
|
||||
SIP/2.0 200 OK
|
||||
]]>
|
||||
</send>
|
||||
<recv response="200"</recv>
|
||||
|
||||
</scenario>
|
||||
@@ -8,13 +8,13 @@
|
||||
<send retrans="500">
|
||||
<![CDATA[
|
||||
|
||||
INVITE sip:16174000006@[remote_ip]:[remote_port] SIP/2.0
|
||||
INVITE sip:[to]@[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]>
|
||||
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: <sip:[to]@[remote_ip]:[remote_port]>
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 INVITE
|
||||
Contact: sip:sipp@[local_ip]:[local_port]
|
||||
Contact: sip:[from]@[local_ip]:[local_port]
|
||||
Max-Forwards: 70
|
||||
X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f
|
||||
Subject: uac-gather-account-creds-success
|
||||
@@ -53,13 +53,13 @@
|
||||
<send>
|
||||
<![CDATA[
|
||||
|
||||
ACK sip:16174000006@[remote_ip]:[remote_port] SIP/2.0
|
||||
ACK sip:[to]@[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]
|
||||
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: [to] <sip:[to]@[remote_ip]:[remote_port]>[peer_tag_param]
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 ACK
|
||||
Contact: sip:sipp@[local_ip]:[local_port]
|
||||
Contact: sip:[from]@[local_ip]:[local_port]
|
||||
Max-Forwards: 70
|
||||
Subject: uac-gather-account-creds-success
|
||||
Content-Length: 0
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
<send retrans="500">
|
||||
<![CDATA[
|
||||
|
||||
INVITE sip:16174000001@[remote_ip]:[remote_port] SIP/2.0
|
||||
INVITE sip:[to]@[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:16174000001@[remote_ip]:[remote_port]>
|
||||
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: <sip:[to]@[remote_ip]:[remote_port]>
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 INVITE
|
||||
Contact: sip:sipp@[local_ip]:[local_port]
|
||||
Contact: sip:[from]@[local_ip]:[local_port]
|
||||
Max-Forwards: 70
|
||||
X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f
|
||||
Subject: uac-say
|
||||
@@ -53,13 +53,13 @@
|
||||
<send>
|
||||
<![CDATA[
|
||||
|
||||
ACK sip:16174000001@[remote_ip]:[remote_port] SIP/2.0
|
||||
ACK sip:[to]@[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: 16174000001 <sip:16174000001@[remote_ip]:[remote_port]>[peer_tag_param]
|
||||
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: [to] <sip:[to]@[remote_ip]:[remote_port]>[peer_tag_param]
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 ACK
|
||||
Contact: sip:sipp@[local_ip]:[local_port]
|
||||
Contact: sip:[from]@[local_ip]:[local_port]
|
||||
Max-Forwards: 70
|
||||
Subject: uac-say
|
||||
Content-Length: 0
|
||||
92
test/scenarios/uac-success-send-bye.xml
Normal file
92
test/scenarios/uac-success-send-bye.xml
Normal file
@@ -0,0 +1,92 @@
|
||||
<?xml version="1.0" encoding="ISO-8859-1" ?>
|
||||
<!DOCTYPE scenario SYSTEM "sipp.dtd">
|
||||
|
||||
|
||||
<scenario name="Basic Sipstone UAC">
|
||||
<!-- In client mode (sipp placing calls), the Call-ID MUST be -->
|
||||
<!-- generated by sipp. To do so, use [call_id] keyword. -->
|
||||
<send retrans="500">
|
||||
<![CDATA[
|
||||
|
||||
INVITE sip:[to]@[remote_ip]:[remote_port] SIP/2.0
|
||||
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
||||
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: <sip:[to]@[remote_ip]:[remote_port]>
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 INVITE
|
||||
Contact: sip:[from]@[local_ip]:[local_port]
|
||||
Max-Forwards: 70
|
||||
X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f
|
||||
Subject: uac-say
|
||||
Content-Type: application/sdp
|
||||
Content-Length: [len]
|
||||
|
||||
v=0
|
||||
o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip]
|
||||
s=-
|
||||
c=IN IP[media_ip_type] [media_ip]
|
||||
t=0 0
|
||||
m=audio [media_port] RTP/AVP 0
|
||||
a=rtpmap:0 PCMU/8000
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<recv response="100"
|
||||
optional="true">
|
||||
</recv>
|
||||
|
||||
<recv response="180" optional="true">
|
||||
</recv>
|
||||
|
||||
<recv response="183" optional="true">
|
||||
</recv>
|
||||
|
||||
<!-- By adding rrs="true" (Record Route Sets), the route sets -->
|
||||
<!-- are saved and used for following messages sent. Useful to test -->
|
||||
<!-- against stateful SIP proxies/B2BUAs. -->
|
||||
<recv response="200" rtd="true">
|
||||
</recv>
|
||||
|
||||
<!-- Packet lost can be simulated in any send/recv message by -->
|
||||
<!-- by adding the 'lost = "10"'. Value can be [1-100] percent. -->
|
||||
<send>
|
||||
<![CDATA[
|
||||
|
||||
ACK sip:[to]@[remote_ip]:[remote_port] SIP/2.0
|
||||
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
||||
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: [to] <sip:[to]@[remote_ip]:[remote_port]>[peer_tag_param]
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 ACK
|
||||
Contact: sip:[from]@[local_ip]:[local_port]
|
||||
Max-Forwards: 70
|
||||
Subject: uac-say
|
||||
Content-Length: 0
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<pause milliseconds="3000"/>
|
||||
|
||||
<!-- The 'crlf' option inserts a blank line in the statistics report. -->
|
||||
<send retrans="500">
|
||||
<![CDATA[
|
||||
|
||||
BYE sip:sip:[to]@[remote_ip]:[remote_port] SIP/2.0
|
||||
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
||||
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: [to] <sip:[to]@[remote_ip]:[remote_port]>[peer_tag_param]
|
||||
Call-ID: [call_id]
|
||||
CSeq: 2 BYE
|
||||
Max-Forwards: 70
|
||||
Content-Length: 0
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<recv response="200" crlf="true">
|
||||
</recv>
|
||||
|
||||
</scenario>
|
||||
|
||||
164
test/scenarios/uas-dial.xml
Normal file
164
test/scenarios/uas-dial.xml
Normal file
@@ -0,0 +1,164 @@
|
||||
<?xml version="1.0" encoding="ISO-8859-1" ?>
|
||||
<!DOCTYPE scenario SYSTEM "sipp.dtd">
|
||||
|
||||
<!-- This program is free software; you can redistribute it and/or -->
|
||||
<!-- modify it under the terms of the GNU General Public License as -->
|
||||
<!-- published by the Free Software Foundation; either version 2 of the -->
|
||||
<!-- License, or (at your option) any later version. -->
|
||||
<!-- -->
|
||||
<!-- This program is distributed in the hope that it will be useful, -->
|
||||
<!-- but WITHOUT ANY WARRANTY; without even the implied warranty of -->
|
||||
<!-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -->
|
||||
<!-- GNU General Public License for more details. -->
|
||||
<!-- -->
|
||||
<!-- You should have received a copy of the GNU General Public License -->
|
||||
<!-- along with this program; if not, write to the -->
|
||||
<!-- Free Software Foundation, Inc., -->
|
||||
<!-- 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -->
|
||||
<!-- -->
|
||||
<!-- Sipp default 'uas' scenario. -->
|
||||
<!-- -->
|
||||
|
||||
<scenario name="Basic UAS responder">
|
||||
<!-- By adding rrs="true" (Record Route Sets), the route sets -->
|
||||
<!-- are saved and used for following messages sent. Useful to test -->
|
||||
<!-- against stateful SIP proxies/B2BUAs. -->
|
||||
<recv request="INVITE" crlf="true">
|
||||
<action>
|
||||
<ereg regexp=".*" search_in="hdr" header="Subject:" assign_to="1" />
|
||||
</action>
|
||||
</recv>
|
||||
|
||||
<!-- The '[last_*]' keyword is replaced automatically by the -->
|
||||
<!-- specified header if it was present in the last message received -->
|
||||
<!-- (except if it was a retransmission). If the header was not -->
|
||||
<!-- present or if no message has been received, the '[last_*]' -->
|
||||
<!-- keyword is discarded, and all bytes until the end of the line -->
|
||||
<!-- are also discarded. -->
|
||||
<!-- -->
|
||||
<!-- If the specified header was present several times in the -->
|
||||
<!-- message, all occurrences are concatenated (CRLF separated) -->
|
||||
<!-- to be used in place of the '[last_*]' keyword. -->
|
||||
|
||||
<send>
|
||||
<![CDATA[
|
||||
|
||||
SIP/2.0 180 Ringing
|
||||
[last_Via:]
|
||||
[last_From:]
|
||||
[last_To:];tag=[pid]SIPpTag01[call_number]
|
||||
[last_Call-ID:]
|
||||
[last_CSeq:]
|
||||
[last_Record-Route:]
|
||||
Subject:[$1]
|
||||
Content-Length: 0
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<send>
|
||||
<![CDATA[
|
||||
|
||||
SIP/2.0 200 OK
|
||||
[last_Via:]
|
||||
[last_From:]
|
||||
[last_To:];tag=[pid]SIPpTag01[call_number]
|
||||
[last_Call-ID:]
|
||||
[last_CSeq:]
|
||||
[last_Record-Route:]
|
||||
Subject:[$1]
|
||||
Contact: <sip:[local_ip]:[local_port];transport=[transport]>
|
||||
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 request="ACK"
|
||||
rtd="true"
|
||||
crlf="true">
|
||||
</recv>
|
||||
|
||||
<recv request="INFO" optional="true" next="1">
|
||||
</recv>
|
||||
|
||||
<recv request="INVITE" crlf="true">
|
||||
</recv>
|
||||
|
||||
<send>
|
||||
<![CDATA[
|
||||
|
||||
SIP/2.0 200 OK
|
||||
[last_Via:]
|
||||
[last_From:]
|
||||
[last_To:];tag=[pid]SIPpTag01[call_number]
|
||||
[last_Call-ID:]
|
||||
[last_CSeq:]
|
||||
[last_Record-Route:]
|
||||
Subject:[$1]
|
||||
Contact: <sip:[local_ip]:[local_port];transport=[transport]>
|
||||
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 request="ACK"
|
||||
rtd="true"
|
||||
crlf="true">
|
||||
</recv>
|
||||
|
||||
<recv request="BYE">
|
||||
</recv>
|
||||
|
||||
<send next="2">
|
||||
<![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>
|
||||
|
||||
<label id="1"/>
|
||||
<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>
|
||||
|
||||
<label id="2"/>
|
||||
|
||||
</scenario>
|
||||
|
||||
99
test/scenarios/uas-timeout-cancel.xml
Normal file
99
test/scenarios/uas-timeout-cancel.xml
Normal file
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="ISO-8859-1" ?>
|
||||
<!DOCTYPE scenario SYSTEM "sipp.dtd">
|
||||
|
||||
<!-- This program is free software; you can redistribute it and/or -->
|
||||
<!-- modify it under the terms of the GNU General Public License as -->
|
||||
<!-- published by the Free Software Foundation; either version 2 of the -->
|
||||
<!-- License, or (at your option) any later version. -->
|
||||
<!-- -->
|
||||
<!-- This program is distributed in the hope that it will be useful, -->
|
||||
<!-- but WITHOUT ANY WARRANTY; without even the implied warranty of -->
|
||||
<!-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -->
|
||||
<!-- GNU General Public License for more details. -->
|
||||
<!-- -->
|
||||
<!-- You should have received a copy of the GNU General Public License -->
|
||||
<!-- along with this program; if not, write to the -->
|
||||
<!-- Free Software Foundation, Inc., -->
|
||||
<!-- 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -->
|
||||
<!-- -->
|
||||
<!-- Sipp default 'uas' scenario. -->
|
||||
<!-- -->
|
||||
|
||||
<scenario name="UAS Timeout Receive Cancel">
|
||||
<!-- By adding rrs="true" (Record Route Sets), the route sets -->
|
||||
<!-- are saved and used for following messages sent. Useful to test -->
|
||||
<!-- against stateful SIP proxies/B2BUAs. -->
|
||||
<recv request="INVITE" crlf="true">
|
||||
<action>
|
||||
<ereg regexp=".*" search_in="hdr" header="Subject:" assign_to="1" />
|
||||
<ereg regexp=".*" search_in="hdr" header="CSeq:" check_it="false" assign_to="2"/>
|
||||
</action>
|
||||
</recv>
|
||||
|
||||
<!-- The '[last_*]' keyword is replaced automatically by the -->
|
||||
<!-- specified header if it was present in the last message received -->
|
||||
<!-- (except if it was a retransmission). If the header was not -->
|
||||
<!-- present or if no message has been received, the '[last_*]' -->
|
||||
<!-- keyword is discarded, and all bytes until the end of the line -->
|
||||
<!-- are also discarded. -->
|
||||
<!-- -->
|
||||
<!-- If the specified header was present several times in the -->
|
||||
<!-- message, all occurrences are concatenated (CRLF separated) -->
|
||||
<!-- to be used in place of the '[last_*]' keyword. -->
|
||||
|
||||
<send>
|
||||
<![CDATA[
|
||||
|
||||
SIP/2.0 180 Ringing
|
||||
[last_Via:]
|
||||
[last_From:]
|
||||
[last_To:];tag=[pid]SIPpTag01[call_number]
|
||||
[last_Call-ID:]
|
||||
[last_CSeq:]
|
||||
[last_Record-Route:]
|
||||
Subject:[$1]
|
||||
Content-Length: 0
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<recv request="CANCEL" >
|
||||
</recv>
|
||||
|
||||
<send>
|
||||
<![CDATA[
|
||||
|
||||
SIP/2.0 200 OK
|
||||
[last_Via:]
|
||||
[last_From:]
|
||||
[last_To:];tag=[pid]SIPpTag01[call_number]
|
||||
[last_Call-ID:]
|
||||
[last_CSeq:]
|
||||
Subject:[$1]
|
||||
Contact: <sip:[local_ip]:[local_port];transport=[transport]>
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<send>
|
||||
<![CDATA[
|
||||
|
||||
SIP/2.0 487 Request Terminated
|
||||
[last_Via:]
|
||||
[last_From:]
|
||||
[last_To:];tag=[pid]SIPpTag01[call_number]
|
||||
[last_Call-ID:]
|
||||
CSeq: [$2]
|
||||
[last_Record-Route:]
|
||||
Subject:[$1]
|
||||
Contact: <sip:[local_ip]:[local_port];transport=[transport]>
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<recv request="ACK"
|
||||
rtd="true"
|
||||
crlf="true">
|
||||
</recv>
|
||||
</scenario>
|
||||
|
||||
100
test/sip-refer-tests.js
Normal file
100
test/sip-refer-tests.js
Normal file
@@ -0,0 +1,100 @@
|
||||
const test = require('tape');
|
||||
const { sippUac } = require('./sipp')('test_fs');
|
||||
const clearModule = require('clear-module');
|
||||
const {provisionCallHook, provisionCustomHook, provisionActionHook} = require('./utils')
|
||||
const bent = require('bent');
|
||||
const getJSON = bent('json')
|
||||
|
||||
const sleepFor = async(ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
});
|
||||
|
||||
function connect(connectable) {
|
||||
return new Promise((resolve, reject) => {
|
||||
connectable.on('connect', () => {
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('\'refer\' tests w/202 and NOTIFY', {timeout: 25000}, async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
// GIVEN
|
||||
const verbs = [
|
||||
{
|
||||
verb: 'say',
|
||||
text: 'silence_stream://100'
|
||||
},
|
||||
{
|
||||
verb: 'sip:refer',
|
||||
referTo: '123456',
|
||||
actionHook: '/actionHook'
|
||||
}
|
||||
];
|
||||
const noVerbs = [];
|
||||
|
||||
const from = 'refer_with_notify';
|
||||
provisionCallHook(from, verbs);
|
||||
provisionActionHook(from, noVerbs)
|
||||
|
||||
// THEN
|
||||
await sippUac('uac-refer-with-notify.xml', '172.38.0.10', from);
|
||||
t.pass('refer: successfully received 202 Accepted');
|
||||
await sleepFor(1000);
|
||||
const obj = await getJSON(`http:127.0.0.1:3100/lastRequest/${from}_actionHook`);
|
||||
t.ok(obj.body.final_referred_call_status === 200, 'refer: successfully received NOTIFY with 200 OK');
|
||||
//console.log(`obj: ${JSON.stringify(obj)}`);
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'refer\' tests w/202 but no NOTIFY', {timeout: 25000}, async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
// GIVEN
|
||||
const verbs = [
|
||||
{
|
||||
verb: 'say',
|
||||
text: 'silence_stream://100'
|
||||
},
|
||||
{
|
||||
verb: 'sip:refer',
|
||||
referTo: '123456',
|
||||
actionHook: '/actionHook'
|
||||
}
|
||||
];
|
||||
const noVerbs = [];
|
||||
|
||||
const from = 'refer_no_notify';
|
||||
provisionCallHook(from, verbs);
|
||||
provisionActionHook(from, noVerbs)
|
||||
|
||||
// THEN
|
||||
await sippUac('uac-refer-no-notify.xml', '172.38.0.10', from);
|
||||
t.pass('refer: successfully received 202 Accepted w/o NOTIFY');
|
||||
await sleepFor(17000);
|
||||
const obj = await getJSON(`http:127.0.0.1:3100/lastRequest/${from}_actionHook`);
|
||||
console.log(`obj: ${JSON.stringify(obj)}`);
|
||||
t.ok(obj.body.refer_status === 202, 'refer: successfully timed out and reported 202');
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
@@ -3,6 +3,7 @@ const { sippUac } = require('./sipp')('test_fs');
|
||||
const bent = require('bent');
|
||||
const getJSON = bent('json')
|
||||
const clearModule = require('clear-module');
|
||||
const {provisionCallHook} = require('./utils')
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
@@ -22,9 +23,28 @@ test('sending SIP in-dialog requests tests', async(t) => {
|
||||
|
||||
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');
|
||||
//GIVEN
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "say",
|
||||
"text": "hello"
|
||||
},
|
||||
{
|
||||
"verb": "sip:request",
|
||||
"method": "info",
|
||||
"headers": {
|
||||
"Content-Type": "application/text"
|
||||
},
|
||||
"body": "here I am ",
|
||||
"actionHook": "/actionHook"
|
||||
}
|
||||
];
|
||||
let from = "sip_indialog_test";
|
||||
provisionCallHook(from, verbs);
|
||||
// THEN
|
||||
await sippUac('uac-send-info-during-dialog.xml', '172.38.0.10', from);
|
||||
const obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
|
||||
t.ok(obj.body.sip_status === 200, 'successfully sent SIP INFO');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
|
||||
@@ -53,6 +53,13 @@ test('incoming call tests', (t) => {
|
||||
.then(() => {
|
||||
return t.pass('handles in-dialog requests');
|
||||
})
|
||||
.then(() => {
|
||||
return sippUac('uac-refer-no-notify.xml', '172.38.0.30');
|
||||
})
|
||||
.then(() => {
|
||||
return t.pass('handles sip:refer where we get 202 but no NOTIFY');
|
||||
})
|
||||
|
||||
.then(() => {
|
||||
srf.disconnect();
|
||||
t.end();
|
||||
|
||||
12
test/sipp.js
12
test/sipp.js
@@ -24,22 +24,24 @@ obj.output = () => {
|
||||
return output;
|
||||
};
|
||||
|
||||
obj.sippUac = (file, bindAddress) => {
|
||||
obj.sippUac = (file, bindAddress, from='sipp', to='16174000000', loop=1) => {
|
||||
const cmd = 'docker';
|
||||
const args = [
|
||||
'run', '-t', '--rm', '--net', `${network}`,
|
||||
'-v', `${__dirname}/scenarios:/tmp/scenarios`,
|
||||
'drachtio/sipp', 'sipp', '-sf', `/tmp/scenarios/${file}`,
|
||||
'-m', '1',
|
||||
'-m', loop,
|
||||
'-sleep', '250ms',
|
||||
'-nostdin',
|
||||
'-cid_str', `%u-%p@%s-${idx++}`,
|
||||
'172.38.0.50'
|
||||
'172.38.0.50',
|
||||
'-key','from', from,
|
||||
'-key','to', to, '-trace_msg'
|
||||
];
|
||||
|
||||
if (bindAddress) args.splice(5, 0, '--ip', bindAddress);
|
||||
|
||||
console.log(args.join(' '));
|
||||
//console.log(args.join(' '));
|
||||
clearOutput();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -61,7 +63,7 @@ obj.sippUac = (file, bindAddress) => {
|
||||
addOutput(data.toString());
|
||||
});
|
||||
child_process.stdout.on('data', (data) => {
|
||||
//console.log(`stdout: ${data}`);
|
||||
// console.log(`stdout: ${data}`);
|
||||
addOutput(data.toString());
|
||||
});
|
||||
});
|
||||
|
||||
219
test/transcribe-tests.js
Normal file
219
test/transcribe-tests.js
Normal file
@@ -0,0 +1,219 @@
|
||||
const test = require('tape');
|
||||
const { sippUac } = require('./sipp')('test_fs');
|
||||
const bent = require('bent');
|
||||
const getJSON = bent('json')
|
||||
const clearModule = require('clear-module');
|
||||
const {provisionCallHook} = require('./utils')
|
||||
const {
|
||||
GCP_JSON_KEY,
|
||||
AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY,
|
||||
MICROSOFT_REGION,
|
||||
MICROSOFT_API_KEY,
|
||||
SONIOX_API_KEY,
|
||||
DEEPGRAM_API_KEY,
|
||||
} = require('../lib/config');
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
});
|
||||
|
||||
function connect(connectable) {
|
||||
return new Promise((resolve, reject) => {
|
||||
connectable.on('connect', () => {
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('\'transcribe\' test - google', async(t) => {
|
||||
if (!GCP_JSON_KEY) {
|
||||
t.pass('skipping google tests');
|
||||
return t.end();
|
||||
}
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
// GIVEN
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "transcribe",
|
||||
"recognizer": {
|
||||
"vendor": "google",
|
||||
"hints": ["customer support", "sales", "human resources", "HR"]
|
||||
},
|
||||
"transcriptionHook": "/transcriptionHook"
|
||||
}
|
||||
];
|
||||
let from = "gather_success";
|
||||
provisionCallHook(from, verbs);
|
||||
// THEN
|
||||
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
|
||||
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
|
||||
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
|
||||
'transcribe: succeeds when using google credentials');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'transcribe\' test - microsoft', async(t) => {
|
||||
if (!MICROSOFT_REGION || !MICROSOFT_API_KEY) {
|
||||
t.pass('skipping microsoft tests');
|
||||
return t.end();
|
||||
}
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
// GIVEN
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "transcribe",
|
||||
"recognizer": {
|
||||
"vendor": "microsoft",
|
||||
"hints": ["customer support", "sales", "human resources", "HR"]
|
||||
},
|
||||
"transcriptionHook": "/transcriptionHook"
|
||||
}
|
||||
];
|
||||
let from = "gather_success";
|
||||
provisionCallHook(from, verbs);
|
||||
// THEN
|
||||
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
|
||||
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
|
||||
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
|
||||
'transcribe: succeeds when using microsoft credentials');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'transcribe\' test - aws', async(t) => {
|
||||
if (!AWS_ACCESS_KEY_ID || !AWS_SECRET_ACCESS_KEY) {
|
||||
t.pass('skipping aws tests');
|
||||
return t.end();
|
||||
}
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
// GIVEN
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "transcribe",
|
||||
"recognizer": {
|
||||
"vendor": "aws",
|
||||
"hints": ["customer support", "sales", "human resources", "HR"]
|
||||
},
|
||||
"transcriptionHook": "/transcriptionHook"
|
||||
}
|
||||
];
|
||||
let from = "gather_success";
|
||||
provisionCallHook(from, verbs);
|
||||
// THEN
|
||||
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
|
||||
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
|
||||
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
|
||||
'transcribe: succeeds when using aws credentials');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'transcribe\' test - deepgram', async(t) => {
|
||||
if (!DEEPGRAM_API_KEY ) {
|
||||
t.pass('skipping deepgram tests');
|
||||
return t.end();
|
||||
}
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
// GIVEN
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "transcribe",
|
||||
"recognizer": {
|
||||
"vendor": "deepgram",
|
||||
"hints": ["customer support", "sales", "human resources", "HR"],
|
||||
"deepgramOptions": {
|
||||
"apiKey": DEEPGRAM_API_KEY
|
||||
}
|
||||
},
|
||||
"transcriptionHook": "/transcriptionHook"
|
||||
}
|
||||
];
|
||||
let from = "gather_success";
|
||||
provisionCallHook(from, verbs);
|
||||
// THEN
|
||||
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
|
||||
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
|
||||
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().includes('like to speak to customer support'),
|
||||
'transcribe: succeeds when using deepgram credentials');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'transcribe\' test - soniox', async(t) => {
|
||||
if (!SONIOX_API_KEY ) {
|
||||
t.pass('skipping soniox tests');
|
||||
return t.end();
|
||||
}
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
// GIVEN
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "transcribe",
|
||||
"recognizer": {
|
||||
"vendor": "soniox",
|
||||
"hints": ["customer support", "sales", "human resources", "HR"],
|
||||
"deepgramOptions": {
|
||||
"apiKey": SONIOX_API_KEY
|
||||
}
|
||||
},
|
||||
"transcriptionHook": "/transcriptionHook"
|
||||
}
|
||||
];
|
||||
let from = "gather_success";
|
||||
provisionCallHook(from, verbs);
|
||||
// THEN
|
||||
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
|
||||
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
|
||||
//console.log(JSON.stringify(obj));
|
||||
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
|
||||
'transcribe: succeeds when using soniox credentials');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
@@ -44,10 +44,25 @@ test('unit tests', (t) => {
|
||||
|
||||
task = makeTask(logger, require('./data/good/say-text-array'));
|
||||
t.ok(task.name === 'say', 'parsed say with multiple segments');
|
||||
|
||||
task = makeTask(logger, require('./data/good/say-ssml'));
|
||||
// the ssml is more than 1000 chars,
|
||||
// expecting first chunk is length > 100, stop at ? instead of first .
|
||||
// 2nd chunk is long text < 1000 char, stop at .
|
||||
// 3rd chunk is the rest.
|
||||
t.ok(task.text.length === 3 &&
|
||||
task.text[0].length === 187 &&
|
||||
task.text[1].length === 882 &&
|
||||
task.text[2].length === 123, 'parsed say');
|
||||
|
||||
task = makeTask(logger, require('./data/bad/bad-say-ssml'));
|
||||
t.ok(task.text.length === 1 &&
|
||||
task.text[0].length === 1162, 'parsed bad say');
|
||||
|
||||
|
||||
const alt = require('./data/good/alternate-syntax');
|
||||
const normalize = require('../lib/utils/normalize-jambones');
|
||||
normalize(logger, alt).forEach((t) => {
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
normalizeJambones(logger, alt).forEach((t) => {
|
||||
const task = makeTask(logger, t);
|
||||
});
|
||||
t.pass('alternate syntax works');
|
||||
@@ -62,4 +77,4 @@ const errMissingProperty = () => makeTask(logger, require('./data/bad/missing-re
|
||||
const errInvalidType = () => makeTask(logger, require('./data/bad/invalid-type'));
|
||||
const errBadEnum = () => makeTask(logger, require('./data/bad/bad-enum'));
|
||||
const errBadPayload = () => makeTask(logger, require('./data/bad/bad-payload'));
|
||||
const errBadPayload2 = () => makeTask(logger, require('./data/bad/bad-payload2'));
|
||||
const errBadPayload2 = () => makeTask(logger, require('./data/bad/bad-payload2'));
|
||||
36
test/utils.js
Normal file
36
test/utils.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const bent = require('bent');
|
||||
|
||||
/*
|
||||
* phoneNumber: 16174000000
|
||||
* Hook endpoints http://127.0.0.1:3100/
|
||||
* The function help testcase to register desired jambonz json response for an application call
|
||||
* When a call has From number match the registered hook event, the desired jambonz json will be responded.
|
||||
*/
|
||||
const provisionCallHook = (from, verbs) => {
|
||||
const mapping = {
|
||||
from,
|
||||
data: JSON.stringify(verbs)
|
||||
};
|
||||
const post = bent('http://127.0.0.1:3100', 'POST', 'string', 200);
|
||||
post('/appMapping', mapping);
|
||||
}
|
||||
|
||||
const provisionCustomHook = (from, verbs) => {
|
||||
const mapping = {
|
||||
from,
|
||||
data: JSON.stringify(verbs)
|
||||
};
|
||||
const post = bent('http://127.0.0.1:3100', 'POST', 'string', 200);
|
||||
post(`/customHookMapping`, mapping);
|
||||
}
|
||||
|
||||
const provisionActionHook = (from, verbs) => {
|
||||
const mapping = {
|
||||
from,
|
||||
data: JSON.stringify(verbs)
|
||||
};
|
||||
const post = bent('http://127.0.0.1:3100', 'POST', 'string', 200);
|
||||
post(`/actionHook`, mapping);
|
||||
}
|
||||
|
||||
module.exports = { provisionCallHook, provisionCustomHook, provisionActionHook}
|
||||
@@ -1,15 +1,23 @@
|
||||
FROM node:alpine as builder
|
||||
RUN apk update && apk add --no-cache python make g++
|
||||
WORKDIR /opt/app/
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
RUN npm prune
|
||||
FROM --platform=linux/amd64 node:18.6.0-alpine as base
|
||||
|
||||
FROM node:alpine as webapp
|
||||
RUN apk add curl
|
||||
WORKDIR /opt/app
|
||||
COPY . /opt/app
|
||||
COPY --from=builder /opt/app/node_modules ./node_modules
|
||||
COPY ./entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
RUN apk --update --no-cache add --virtual .builds-deps build-base python3
|
||||
|
||||
WORKDIR /opt/app/
|
||||
|
||||
FROM base as build
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
FROM base
|
||||
|
||||
COPY --from=build /opt/app /opt/app/
|
||||
|
||||
ARG NODE_ENV
|
||||
|
||||
ENV NODE_ENV $NODE_ENV
|
||||
|
||||
CMD [ "node", "app.js" ]
|
||||
|
||||
@@ -1,49 +1,190 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
const Websocket = require('ws');
|
||||
const listenPort = process.env.HTTP_PORT || 3000;
|
||||
let lastAction, lastEvent;
|
||||
let json_mapping = new Map();
|
||||
let hook_mapping = new Map();
|
||||
let ws_packet_count = new Map();
|
||||
let ws_metadata = new Map();
|
||||
|
||||
assert.ok(process.env.APP_PATH, 'env var APP_PATH is required');
|
||||
/** websocket server for listen audio */
|
||||
const recvAudio = (socket, req) => {
|
||||
let packets = 0;
|
||||
let path = req.url;
|
||||
console.log('received websocket connection');
|
||||
socket.on('message', (data, isBinary) => {
|
||||
if (!isBinary) {
|
||||
try {
|
||||
const msg = JSON.parse(data);
|
||||
console.log({msg}, 'received websocket message');
|
||||
ws_metadata.set(path, msg);
|
||||
}
|
||||
catch (err) {
|
||||
console.log({err}, 'error parsing websocket message');
|
||||
}
|
||||
}
|
||||
else {
|
||||
packets += data.length;
|
||||
}
|
||||
});
|
||||
socket.on('error', (err) => {
|
||||
console.log({err}, 'listen websocket: error');
|
||||
});
|
||||
|
||||
app.listen(listenPort, () => {
|
||||
socket.on('close', () => {
|
||||
ws_packet_count.set(path, packets);
|
||||
})
|
||||
};
|
||||
|
||||
const wsServer = new Websocket.Server({ noServer: true });
|
||||
wsServer.setMaxListeners(0);
|
||||
wsServer.on('connection', recvAudio.bind(null));
|
||||
|
||||
const server = app.listen(listenPort, () => {
|
||||
console.log(`sample jambones app server listening on ${listenPort}`);
|
||||
});
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
console.log('received upgrade request');
|
||||
wsServer.handleUpgrade(request, socket, head, (socket) => {
|
||||
wsServer.emit('connection', socket, request);
|
||||
});
|
||||
});
|
||||
|
||||
const applicationData = JSON.parse(fs.readFileSync(process.env.APP_PATH));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
|
||||
/*
|
||||
* Markup language
|
||||
*/
|
||||
|
||||
app.all('/', (req, res) => {
|
||||
console.log(applicationData, `${req.method} /`);
|
||||
return res.json(applicationData);
|
||||
console.log(req.body, 'POST /');
|
||||
const key = req.body.from
|
||||
addRequestToMap(key, req, hook_mapping);
|
||||
return getJsonFromMap(key, req, res);
|
||||
});
|
||||
|
||||
app.post('/appMapping', (req, res) => {
|
||||
console.log(req.body, 'POST /appMapping');
|
||||
json_mapping.set(req.body.from, req.body.data);
|
||||
return res.sendStatus(200);
|
||||
});
|
||||
|
||||
/*
|
||||
* Status Callback
|
||||
*/
|
||||
app.post('/callStatus', (req, res) => {
|
||||
console.log({payload: req.body}, 'POST /callStatus');
|
||||
let key = req.body.from + "_callStatus"
|
||||
addRequestToMap(key, req, hook_mapping);
|
||||
return res.sendStatus(200);
|
||||
});
|
||||
|
||||
/*
|
||||
* transcriptionHook
|
||||
*/
|
||||
app.post('/transcriptionHook', (req, res) => {
|
||||
console.log({payload: req.body}, 'POST /transcriptionHook');
|
||||
let key = req.body.from + "_actionHook"
|
||||
addRequestToMap(key, req, hook_mapping);
|
||||
return res.json([{"verb": "hangup"}]);
|
||||
});
|
||||
/*
|
||||
* actionHook
|
||||
*/
|
||||
app.post('/actionHook', (req, res) => {
|
||||
console.log({payload: req.body}, 'POST /actionHook');
|
||||
lastAction = req.body;
|
||||
let key = req.body.from + "_actionHook"
|
||||
addRequestToMap(key, req, hook_mapping);
|
||||
return res.sendStatus(200);
|
||||
});
|
||||
|
||||
app.get('/actionHook', (req, res) => {
|
||||
console.log({payload: lastAction}, 'GET /actionHook');
|
||||
return res.json(lastAction);
|
||||
/*
|
||||
* customHook
|
||||
* For the hook to return
|
||||
*/
|
||||
|
||||
app.all('/customHook', (req, res) => {
|
||||
let key = `${req.body.from}_customHook`;;
|
||||
console.log(req.body, `POST /customHook`);
|
||||
return getJsonFromMap(key, req, res);
|
||||
});
|
||||
|
||||
app.post('/eventHook', (req, res) => {
|
||||
console.log({payload: req.body}, 'POST /eventHook');
|
||||
lastEvent = req.body;
|
||||
app.post('/customHookMapping', (req, res) => {
|
||||
let key = `${req.body.from}_customHook`;
|
||||
console.log(req.body, `POST /customHookMapping`);
|
||||
json_mapping.set(key, req.body.data);
|
||||
return res.sendStatus(200);
|
||||
});
|
||||
|
||||
app.get('/eventHook', (req, res) => {
|
||||
console.log({payload: lastEvent}, 'GET /eventHook');
|
||||
return res.json(lastEvent);
|
||||
});
|
||||
// Fetch Requests
|
||||
app.get('/requests/:key', (req, res) => {
|
||||
let key = req.params.key;
|
||||
if (hook_mapping.has(key)) {
|
||||
return res.json(hook_mapping.get(key));
|
||||
} else {
|
||||
return res.sendStatus(404);
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
app.get('/lastRequest/:key', (req, res) => {
|
||||
let key = req.params.key;
|
||||
if (hook_mapping.has(key)) {
|
||||
let requests = hook_mapping.get(key);
|
||||
return res.json(requests[requests.length - 1]);
|
||||
} else {
|
||||
return res.sendStatus(404);
|
||||
}
|
||||
})
|
||||
|
||||
// WS Fetch
|
||||
app.get('/ws_packet_count/:key', (req, res) => {
|
||||
let key = `/${req.params.key}`;
|
||||
console.log(key, ws_packet_count);
|
||||
if (ws_packet_count.has(key)) {
|
||||
return res.json({ count: ws_packet_count.get(key) });
|
||||
} else {
|
||||
return res.sendStatus(404);
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/ws_metadata/:key', (req, res) => {
|
||||
let key = `/${req.params.key}`;
|
||||
console.log(key, ws_packet_count);
|
||||
if (ws_metadata.has(key)) {
|
||||
return res.json({ metadata: ws_metadata.get(key) });
|
||||
} else {
|
||||
return res.sendStatus(404);
|
||||
}
|
||||
})
|
||||
|
||||
/*
|
||||
* private function
|
||||
*/
|
||||
|
||||
function getJsonFromMap(key, req, res) {
|
||||
if (!json_mapping.has(key)) return res.sendStatus(404);
|
||||
const retData = JSON.parse(json_mapping.get(key));
|
||||
console.log(retData, ` Response to ${req.method} ${req.url}`);
|
||||
addRequestToMap(key, req, hook_mapping);
|
||||
return res.json(retData);
|
||||
}
|
||||
|
||||
function addRequestToMap(key, req, map) {
|
||||
let headers = new Map()
|
||||
for(let i = 0; i < req.rawHeaders.length; i++) {
|
||||
if (i % 2 === 0) {
|
||||
headers.set(req.rawHeaders[i], req.rawHeaders[i + 1])
|
||||
}
|
||||
}
|
||||
let request = {
|
||||
'url': req.url,
|
||||
'headers': Object.fromEntries(headers),
|
||||
'body': req.body
|
||||
}
|
||||
if (map.has(key)) {
|
||||
map.get(key).push(request);
|
||||
} else {
|
||||
map.set(key, [request]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/sh
|
||||
cd /opt/app/
|
||||
npm start
|
||||
929
test/webhook/package-lock.json
generated
929
test/webhook/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user