mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2026-02-10 08:21:33 +00:00
Compare commits
1 Commits
fix/transc
...
v0.4.1-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fff52c930c |
@@ -8,7 +8,7 @@
|
|||||||
"jsx": false,
|
"jsx": false,
|
||||||
"modules": false
|
"modules": false
|
||||||
},
|
},
|
||||||
"ecmaVersion": 2020
|
"ecmaVersion": 2017
|
||||||
},
|
},
|
||||||
"plugins": ["promise"],
|
"plugins": ["promise"],
|
||||||
"rules": {
|
"rules": {
|
||||||
|
|||||||
23
.github/workflows/build.yml
vendored
23
.github/workflows/build.yml
vendored
@@ -1,23 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on: [push, pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: 18
|
|
||||||
- run: npm ci
|
|
||||||
- run: npm run jslint
|
|
||||||
- run: docker pull drachtio/sipp
|
|
||||||
- run: npm test
|
|
||||||
env:
|
|
||||||
GCP_JSON_KEY: ${{ secrets.GCP_JSON_KEY }}
|
|
||||||
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 }}
|
|
||||||
54
.github/workflows/docker-publish.yml
vendored
54
.github/workflows/docker-publish.yml
vendored
@@ -1,54 +0,0 @@
|
|||||||
name: Docker
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
tags:
|
|
||||||
- '*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
push:
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: prepare tag
|
|
||||||
id: prepare_tag
|
|
||||||
run: |
|
|
||||||
IMAGE_ID=jambonz/feature-server
|
|
||||||
|
|
||||||
# Strip git ref prefix from version
|
|
||||||
VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
|
|
||||||
|
|
||||||
# Strip "v" prefix from tag name
|
|
||||||
[[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')
|
|
||||||
|
|
||||||
# Use Docker `latest` tag convention
|
|
||||||
[ "$VERSION" == "main" ] && VERSION=latest
|
|
||||||
|
|
||||||
echo IMAGE_ID=$IMAGE_ID
|
|
||||||
echo VERSION=$VERSION
|
|
||||||
|
|
||||||
echo "image_id=$IMAGE_ID" >> $GITHUB_OUTPUT
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- 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
@@ -39,6 +39,3 @@ examples/*
|
|||||||
|
|
||||||
ecosystem.config.js
|
ecosystem.config.js
|
||||||
.vscode
|
.vscode
|
||||||
test/credentials/*.json
|
|
||||||
run-tests.sh
|
|
||||||
run-coverage.sh
|
|
||||||
|
|||||||
6
.travis.yml
Normal file
6
.travis.yml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
sudo: required
|
||||||
|
language: node_js
|
||||||
|
node_js:
|
||||||
|
- "lts/*"
|
||||||
|
script:
|
||||||
|
- npm test
|
||||||
26
Dockerfile
26
Dockerfile
@@ -1,23 +1,13 @@
|
|||||||
FROM --platform=linux/amd64 node:18.15-alpine3.16 as base
|
FROM node:lts-alpine
|
||||||
|
|
||||||
RUN apk --update --no-cache add --virtual .builds-deps build-base python3
|
RUN mkdir -p /usr/src/app
|
||||||
|
WORKDIR /usr/src/app
|
||||||
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
|
ARG NODE_ENV
|
||||||
|
|
||||||
ENV NODE_ENV $NODE_ENV
|
ENV NODE_ENV $NODE_ENV
|
||||||
|
|
||||||
CMD [ "node", "app.js" ]
|
COPY package.json /usr/src/app/
|
||||||
|
RUN npm install
|
||||||
|
COPY . /usr/src/app
|
||||||
|
|
||||||
|
CMD [ "npm", "start" ]
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2021 Drachtio Communications Services, LLC
|
Copyright (c) 2019 jambonz
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
159
README.md
159
README.md
@@ -1,97 +1,82 @@
|
|||||||
# jambonz-feature-server [](https://github.com/jambonz/jambonz-feature-server/actions/workflows/build.yml)
|
# jambones-feature-server [](http://travis-ci.org/jambonz/jambones-feature-server)
|
||||||
|
|
||||||
This application implements the core feature server of the jambones platform.
|
This application implements the core feature server of the jambones platform.
|
||||||
|
|
||||||
> Note: If you are a developer looking to work on the code please read our [how-to for that](./docs/contributing.md).
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Configuration is provided via environment variables:
|
Configuration is provided via the [npmjs config](https://www.npmjs.com/package/config) package. The following elements make up the configuration for the application:
|
||||||
|
##### drachtio server location
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"drachtio": {
|
||||||
|
"port": 3001,
|
||||||
|
"secret": "cymru"
|
||||||
|
},
|
||||||
|
```
|
||||||
|
the `drachtio` object specifies the port to listen on for tcp connections from drachtio servers as well as the shared secret that is used to authenticate to the server.
|
||||||
|
|
||||||
| variable | meaning | required?|
|
> Note: either inbound or [outbound connections](https://drachtio.org/docs#outbound-connections) may be used, depending on the configuration supplied. In production, it is the intent to use outbound connections for easier centralization and clustering of application logic.
|
||||||
|----------|----------|---------|
|
|
||||||
|AWS_ACCESS_KEY_ID| aws access key id, used for TTS/STT as well SNS notifications|no|
|
|
||||||
|AWS_REGION| aws region| no|
|
|
||||||
|AWS_SECRET_ACCESS_KEY| aws secret access key, used per above|no|
|
|
||||||
|AWS_SNS_TOPIC_ARM| aws sns topic arn that scale-in lifecycle notifications will be published to|no|
|
|
||||||
|DRACHTIO_HOST| ip address of drachtio server (typically '127.0.0.1')|yes|
|
|
||||||
|DRACHTIO_PORT| listening port of drachtio server for control connections (typically 9022)|yes|
|
|
||||||
|DRACHTIO_SECRET| shared secret|yes|
|
|
||||||
|ENABLE_METRICS| if 1, metrics will be generated|no|
|
|
||||||
|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|
|
|
||||||
|JAMBONES_MYSQL_USER| mysql username|yes|
|
|
||||||
|JAMBONES_MYSQL_PASSWORD| mysql password|yes|
|
|
||||||
|JAMBONES_MYSQL_DATABASE| mysql data|yes|
|
|
||||||
|JAMBONES_MYSQL_CONNECTION_LIMIT| mysql connection limit |no|
|
|
||||||
|JAMBONES_NETWORK_CIDR| CIDR of private network that feature server is running in (e.g. '172.31.0.0/16')|yes|
|
|
||||||
|JAMBONES_REDIS_HOST| redis host|yes|
|
|
||||||
|JAMBONES_REDIS_PORT|redis port|yes|
|
|
||||||
|JAMBONES_SBCS| list of IP addresses (on the internal network) of SBCs, comma-separated|yes|
|
|
||||||
|STATS_HOST| ip address of metrics host (usually '127.0.0.1' since telegraf is installed locally|no|
|
|
||||||
|STATS_PORT| listening port for metrics host|no|
|
|
||||||
|STATS_PROTOCOL| 'tcp' or 'udp'|no|
|
|
||||||
|STATS_TELEGRAF| if 1, metrics will be generated in telegraf format|no|
|
|
||||||
|JAMBONZ_RECORD_WS_BASE_URL| recording websocket URL to send the recording audio|no|
|
|
||||||
|JAMBONZ_RECORD_WS_USERNAME| recording websocket username|no|
|
|
||||||
|JAMBONZ_RECORD_WS_PASSWORD| recording websocket password|no|
|
|
||||||
|ANCHOR_MEDIA_ALWAYS| keep media on media server|no|
|
|
||||||
|JAMBONZ_DISABLE_DIAL_PAI_HEADER| control P-Asserted-Identity header on B-Leg|no|
|
|
||||||
|
|
||||||
### running under pm2
|
##### freeswitch location
|
||||||
Typically, this application runs under [pm2](https://pm2.io) using an [ecosystem.config.js](https://pm2.keymetrics.io/docs/usage/application-declaration/) file similar to this:
|
```
|
||||||
```js
|
"freeswitch: {
|
||||||
module.exports = {
|
"address": "127.0.0.1",
|
||||||
apps : [
|
"port": 8021,
|
||||||
{
|
"secret": "ClueCon"
|
||||||
name: 'jambonz-feature-server',
|
},
|
||||||
cwd: '/home/admin/apps/jambonz-feature-server',
|
```
|
||||||
script: 'app.js',
|
the `freeswitch` property specifies the location of the freeswitch server to use for media handling.
|
||||||
instance_var: 'INSTANCE_ID',
|
|
||||||
out_file: '/home/admin/.pm2/logs/jambonz-feature-server.log',
|
##### application log level
|
||||||
err_file: '/home/admin/.pm2/logs/jambonz-feature-server.log',
|
```
|
||||||
exec_mode: 'fork',
|
"logging": {
|
||||||
instances: 1,
|
"level": "info"
|
||||||
autorestart: true,
|
}
|
||||||
watch: false,
|
```
|
||||||
max_memory_restart: '1G',
|
##### mysql server location
|
||||||
env: {
|
Login credentials for the mysql server databas.
|
||||||
NODE_ENV: 'production',
|
```
|
||||||
GOOGLE_APPLICATION_CREDENTIALS: '/home/admin/credentials/gcp.json',
|
"mysql": {
|
||||||
AWS_ACCESS_KEY_ID: 'XXXXXXXXXXXX',
|
"host": "127.0.0.1",
|
||||||
AWS_SECRET_ACCESS_KEY: 'YYYYYYYYYYYYYYYYYYYYY',
|
"user": "jambones",
|
||||||
AWS_REGION: 'us-west-1',
|
"password": "jambones",
|
||||||
ENABLE_METRICS: 1,
|
"database": "jambones"
|
||||||
STATS_HOST: '127.0.0.1',
|
}
|
||||||
STATS_PORT: 8125,
|
```
|
||||||
STATS_PROTOCOL: 'tcp',
|
##### redis server location
|
||||||
STATS_TELEGRAF: 1,
|
Login credentials for the redis server databas.
|
||||||
AWS_SNS_TOPIC_ARM: 'arn:aws:sns:us-west-1:xxxxxxxxxxx:terraform-20201107200347128600000002',
|
```
|
||||||
JAMBONES_NETWORK_CIDR: '172.31.0.0/16',
|
"redis": {
|
||||||
JAMBONES_MYSQL_HOST: 'aurora-cluster-jambonz.cluster-yyyyyyyyyyy.us-west-1.rds.amazonaws.com',
|
"host": "127.0.0.1",
|
||||||
JAMBONES_MYSQL_USER: 'admin',
|
"port": 6379
|
||||||
JAMBONES_MYSQL_PASSWORD: 'foobarbz',
|
}
|
||||||
JAMBONES_MYSQL_DATABASE: 'jambones',
|
```
|
||||||
JAMBONES_MYSQL_CONNECTION_LIMIT: 10,
|
|
||||||
JAMBONES_REDIS_HOST: 'jambonz.zzzzzzz.0001.usw1.cache.amazonaws.com',
|
##### port to listen on for HTTP API requests
|
||||||
JAMBONES_REDIS_PORT: 6379,
|
The HTTP listen port can be set by the `HTTP_PORT` environment variable, but it not set the default port will be taken from the configuration file.
|
||||||
JAMBONES_LOGLEVEL: 'debug',
|
|
||||||
HTTP_PORT: 3000,
|
```
|
||||||
DRACHTIO_HOST: '127.0.0.1',
|
"defaultHttpPort": 3000,
|
||||||
DRACHTIO_PORT: 9022,
|
```
|
||||||
DRACHTIO_SECRET: 'sharedsecret',
|
|
||||||
JAMBONES_SBCS: '172.31.32.10',
|
##### REST-initiated outdials
|
||||||
JAMBONES_FREESWITCH: '127.0.0.1:8021:sharedsecret'
|
When an outdial is triggered via the REST API, the application needs to select a drachtio sip server to generate the INVITE, and it needs to know the IP addresses of the SBC(s) to send the outbound call through. Both are provided as arrays in the configuration file, and if more than one is supplied they will be used in a round-robin fashion.
|
||||||
}
|
|
||||||
}]
|
```
|
||||||
};
|
"outdials": {
|
||||||
|
"drachtio": [
|
||||||
|
{
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 9022,
|
||||||
|
"secret": "cymru"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sbc": ["127.0.0.1:5060"]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Running the test suite
|
#### Running the test suite
|
||||||
|
The test suite currently only consists of JSON-parsing unit tests. A full end-to-end sip test suite should be added.
|
||||||
Please [see this](./docs/contributing.md#run-the-regression-test-suite).
|
```
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|||||||
162
app.js
162
app.js
@@ -1,49 +1,46 @@
|
|||||||
const {
|
const assert = require('assert');
|
||||||
DRACHTIO_PORT,
|
assert.ok(process.env.JAMBONES_MYSQL_HOST &&
|
||||||
DRACHTIO_HOST,
|
process.env.JAMBONES_MYSQL_USER &&
|
||||||
DRACHTIO_SECRET,
|
process.env.JAMBONES_MYSQL_PASSWORD &&
|
||||||
JAMBONES_OTEL_SERVICE_NAME,
|
process.env.JAMBONES_MYSQL_DATABASE, 'missing JAMBONES_MYSQL_XXX env vars');
|
||||||
JAMBONES_LOGLEVEL,
|
assert.ok(process.env.DRACHTIO_PORT || process.env.DRACHTIO_HOST, 'missing DRACHTIO_PORT env var');
|
||||||
JAMBONES_CLUSTER_ID,
|
assert.ok(process.env.DRACHTIO_SECRET, 'missing DRACHTIO_SECRET env var');
|
||||||
JAMBONZ_CLEANUP_INTERVAL_MINS,
|
assert.ok(process.env.JAMBONES_FREESWITCH, 'missing JAMBONES_FREESWITCH env var');
|
||||||
getCleanupIntervalMins,
|
assert.ok(process.env.JAMBONES_REDIS_HOST, 'missing JAMBONES_REDIS_HOST env var');
|
||||||
K8S,
|
assert.ok(process.env.JAMBONES_NETWORK_CIDR, 'missing JAMBONES_SUBNET env var');
|
||||||
NODE_ENV,
|
|
||||||
checkEnvs,
|
|
||||||
} = require('./lib/config');
|
|
||||||
|
|
||||||
checkEnvs();
|
|
||||||
|
|
||||||
const Srf = require('drachtio-srf');
|
const Srf = require('drachtio-srf');
|
||||||
const srf = new Srf();
|
const srf = new Srf();
|
||||||
const tracer = require('./tracer')(JAMBONES_OTEL_SERVICE_NAME);
|
const PORT = process.env.HTTP_PORT || 3000;
|
||||||
const api = require('@opentelemetry/api');
|
const opts = Object.assign({
|
||||||
srf.locals = {...srf.locals, otel: {tracer, api}};
|
timestamp: () => {return `, "time": "${new Date().toISOString()}"`;}
|
||||||
|
}, {level: process.env.JAMBONES_LOGLEVEL || 'info'});
|
||||||
const opts = {
|
const logger = require('pino')(opts);
|
||||||
level: JAMBONES_LOGLEVEL
|
const {LifeCycleEvents} = require('./lib/utils/constants');
|
||||||
};
|
|
||||||
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');
|
const installSrfLocals = require('./lib/utils/install-srf-locals');
|
||||||
installSrfLocals(srf, logger);
|
installSrfLocals(srf, logger);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
initLocals,
|
initLocals,
|
||||||
createRootSpan,
|
|
||||||
handleSipRec,
|
|
||||||
getAccountDetails,
|
|
||||||
normalizeNumbers,
|
normalizeNumbers,
|
||||||
retrieveApplication,
|
retrieveApplication,
|
||||||
invokeWebCallback
|
invokeWebCallback
|
||||||
} = require('./lib/middleware')(srf, logger);
|
} = require('./lib/middleware')(srf, logger);
|
||||||
|
|
||||||
const InboundCallSession = require('./lib/session/inbound-call-session');
|
// HTTP
|
||||||
const SipRecCallSession = require('./lib/session/siprec-call-session');
|
const express = require('express');
|
||||||
|
const app = express();
|
||||||
|
Object.assign(app.locals, {
|
||||||
|
logger,
|
||||||
|
srf
|
||||||
|
});
|
||||||
|
|
||||||
if (DRACHTIO_HOST) {
|
const httpRoutes = require('./lib/http-routes');
|
||||||
srf.connect({host: DRACHTIO_HOST, port: DRACHTIO_PORT, secret: DRACHTIO_SECRET });
|
|
||||||
|
const InboundCallSession = require('./lib/session/inbound-call-session');
|
||||||
|
|
||||||
|
if (process.env.DRACHTIO_HOST) {
|
||||||
|
srf.connect({host: process.env.DRACHTIO_HOST, port: process.env.DRACHTIO_PORT, secret: process.env.DRACHTIO_SECRET });
|
||||||
srf.on('connect', (err, hp) => {
|
srf.on('connect', (err, hp) => {
|
||||||
const arr = /^(.*)\/(.*)$/.exec(hp.split(',').pop());
|
const arr = /^(.*)\/(.*)$/.exec(hp.split(',').pop());
|
||||||
srf.locals.localSipAddress = `${arr[2]}`;
|
srf.locals.localSipAddress = `${arr[2]}`;
|
||||||
@@ -51,32 +48,34 @@ if (DRACHTIO_HOST) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
logger.info(`listening for drachtio requests on port ${DRACHTIO_PORT}`);
|
logger.info(`listening for drachtio requests on port ${process.env.DRACHTIO_PORT}`);
|
||||||
srf.listen({port: DRACHTIO_PORT, secret: DRACHTIO_SECRET});
|
srf.listen({port: process.env.DRACHTIO_PORT, secret: process.env.DRACHTIO_SECRET});
|
||||||
}
|
}
|
||||||
if (NODE_ENV === 'test') {
|
if (process.env.NODE_ENV === 'test') {
|
||||||
srf.on('error', (err) => {
|
srf.on('error', (err) => {
|
||||||
logger.info(err, 'Error connecting to drachtio');
|
logger.info(err, 'Error connecting to drachtio');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
srf.use('invite', [
|
srf.use('invite', [initLocals, normalizeNumbers, retrieveApplication, invokeWebCallback]);
|
||||||
initLocals,
|
|
||||||
createRootSpan,
|
|
||||||
handleSipRec,
|
|
||||||
getAccountDetails,
|
|
||||||
normalizeNumbers,
|
|
||||||
retrieveApplication,
|
|
||||||
invokeWebCallback
|
|
||||||
]);
|
|
||||||
|
|
||||||
srf.invite(async(req, res) => {
|
srf.invite((req, res) => {
|
||||||
const isSipRec = !!req.locals.siprec;
|
const session = new InboundCallSession(req, res);
|
||||||
const session = isSipRec ? new SipRecCallSession(req, res) : new InboundCallSession(req, res);
|
|
||||||
if (isSipRec) await session.answerSipRecCall();
|
|
||||||
session.exec();
|
session.exec();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// HTTP
|
||||||
|
app.use(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});
|
||||||
|
});
|
||||||
|
app.listen(PORT);
|
||||||
|
|
||||||
|
logger.info(`listening for HTTP requests on port ${PORT}, serviceUrl is ${srf.locals.serviceUrl}`);
|
||||||
|
|
||||||
const sessionTracker = srf.locals.sessionTracker = require('./lib/session/session-tracker');
|
const sessionTracker = srf.locals.sessionTracker = require('./lib/session/session-tracker');
|
||||||
sessionTracker.on('idle', () => {
|
sessionTracker.on('idle', () => {
|
||||||
if (srf.locals.lifecycleEmitter.operationalState === LifeCycleEvents.ScaleIn) {
|
if (srf.locals.lifecycleEmitter.operationalState === LifeCycleEvents.ScaleIn) {
|
||||||
@@ -84,72 +83,9 @@ sessionTracker.on('idle', () => {
|
|||||||
srf.locals.lifecycleEmitter.scaleIn();
|
srf.locals.lifecycleEmitter.scaleIn();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const getCount = () => sessionTracker.count;
|
|
||||||
const healthCheck = require('@jambonz/http-health-check');
|
|
||||||
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(() => {
|
setInterval(() => {
|
||||||
srf.locals.stats.gauge('fs.sip.calls.count', sessionTracker.count);
|
srf.locals.stats.gauge('fs.sip.calls.count', sessionTracker.count);
|
||||||
}, 20000);
|
}, 5000);
|
||||||
|
|
||||||
const disconnect = () => {
|
module.exports = {srf, logger};
|
||||||
return new Promise ((resolve) => {
|
|
||||||
httpServer?.on('close', resolve);
|
|
||||||
httpServer?.close();
|
|
||||||
srf.disconnect();
|
|
||||||
srf.locals.mediaservers?.forEach((ms) => ms.disconnect());
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
process.on('SIGTERM', handle);
|
|
||||||
|
|
||||||
function handle(signal) {
|
|
||||||
const {removeFromSet} = srf.locals.dbHelpers;
|
|
||||||
srf.locals.disabled = true;
|
|
||||||
logger.info(`got signal ${signal}`);
|
|
||||||
const setName = `${(JAMBONES_CLUSTER_ID || 'default')}:active-fs`;
|
|
||||||
const fsServiceUrlSetName = `${(JAMBONES_CLUSTER_ID || 'default')}:fs-service-url`;
|
|
||||||
if (setName && srf.locals.localSipAddress) {
|
|
||||||
logger.info(`got signal ${signal}, removing ${srf.locals.localSipAddress} from set ${setName}`);
|
|
||||||
removeFromSet(setName, srf.locals.localSipAddress);
|
|
||||||
}
|
|
||||||
if (fsServiceUrlSetName && srf.locals.serviceUrl) {
|
|
||||||
logger.info(`got signal ${signal}, removing ${srf.locals.serviceUrl} from set ${fsServiceUrlSetName}`);
|
|
||||||
removeFromSet(fsServiceUrlSetName, srf.locals.serviceUrl);
|
|
||||||
}
|
|
||||||
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 (JAMBONZ_CLEANUP_INTERVAL_MINS) {
|
|
||||||
const {clearFiles} = require('./lib/utils/cron-jobs');
|
|
||||||
|
|
||||||
/* cleanup orphaned files or channels every so often */
|
|
||||||
setInterval(async() => {
|
|
||||||
try {
|
|
||||||
await clearFiles();
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({err}, 'app.js: error clearing files');
|
|
||||||
}
|
|
||||||
}, getCleanupIntervalMins());
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {srf, logger, disconnect};
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
const bent = require('bent');
|
|
||||||
const getJSON = bent('json');
|
|
||||||
const {PORT} = require('../lib/config')
|
|
||||||
|
|
||||||
const sleep = (ms) => {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
};
|
|
||||||
|
|
||||||
(async function() {
|
|
||||||
|
|
||||||
try {
|
|
||||||
do {
|
|
||||||
const obj = await getJSON(`http://127.0.0.1:${PORT}/`);
|
|
||||||
const {calls} = obj;
|
|
||||||
if (calls === 0) {
|
|
||||||
console.log('no calls on the system, we can exit');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
console.log(`waiting for ${calls} to exit..`);
|
|
||||||
}
|
|
||||||
await sleep(10000);
|
|
||||||
} while (1);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err, 'Error querying health endpoint');
|
|
||||||
process.exit(-1);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
# Contributors are welcome!
|
|
||||||
|
|
||||||
So, you want to hack on jambonz? Maybe add some features, maybe help fix some bugs? Awesome, welcome aboard!
|
|
||||||
|
|
||||||
This brief document should get you started. Here you will find instructions showing how to set up your laptop to run the regression test suite (which you should always run before committing any changes), as well as some basic info on the structure of the code.
|
|
||||||
|
|
||||||
## Getting oriented
|
|
||||||
|
|
||||||
First of all, you are in the right place to begin hacking on jambonz. The jambonz-feature-server app is kinda the center of the universe for jambonz. Most of the core logic in jambonz is implemented here: things like the [webhook verbs](../lib/tasks), [session management](../lib/session), and the [client-side webhook implementation](../lib/utils/http-requestor.js). A common thing you might want to do, for instance, is to add support for an all-new verb, and this code base is where would do that.
|
|
||||||
|
|
||||||
This jambonz-feature-server app works together quite closely with a [drachtio server](https://github.com/drachtio/drachtio-server) and a Freeswitch. In fact, these three components are bundled together into a single VM/instance (or a Deployment, in Kubernetes) that we more generally refer to as "Feature Server". The Feature Server is a horizontally-scalable unit that is deployed behind the public-facing SBC elements of a jambonz cluster (the SBC is itself a separately scalable unit). The drachtio-server handles the SIP signaling, the Freeswitch handles media operations and speech vendor integration, and the jambonz-feature-server app orchestrates all of it via the use of [drachtio-srf](https://github.com/drachtio/drachtio-srf) and [drachtio-fsmrf](https://github.com/drachtio/drachtio-fsmrf).
|
|
||||||
|
|
||||||
## How to do things
|
|
||||||
|
|
||||||
First of all, please join our [slack channel](https://joinslack.jambonz.org) in order to coordinate with us on the work, i.e. to notify us of what you are doing and make sure that no one else is already working on the same thing.
|
|
||||||
|
|
||||||
To prepare to make changes, please fork the repo to your own Github account, make changes, test them on your own running jambonz cluster, then run the regression test suite and lint check before giving us a PR.
|
|
||||||
|
|
||||||
### lint
|
|
||||||
|
|
||||||
We have some opinionated conventions that you must follow - see our [eslintrc.json](../.eslintrc.json) for details. Make sure your code passes by running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run jslint
|
|
||||||
```
|
|
||||||
|
|
||||||
### test suite
|
|
||||||
|
|
||||||
#### Generate speech credentials and create run-tests.sh
|
|
||||||
|
|
||||||
The test suite also requires you to provide speech credentials for both GCP and AWS. You will want to create a new file named `run-tests.sh` in the project folder. Make the file executable and then copy in the text below, substituting your speech credentials where indicated:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
GCP_JSON_KEY='{"type":"service_account","project_id":"...etc"}' \
|
|
||||||
AWS_ACCESS_KEY_ID='your-aws-access-key-id' \
|
|
||||||
AWS_SECRET_ACCESS_KEY='your-aws-secret-access-key' \
|
|
||||||
AWS_REGION='us-east-1' \
|
|
||||||
JWT_SECRET='foobar' \
|
|
||||||
npm test
|
|
||||||
```
|
|
||||||
>> Note: The project's .gitignore file prevents this file from being sent to Github, so you do not need to worry about exposing your credentials. Just make sure you name if run-tests.sh and create it in the project folder
|
|
||||||
|
|
||||||
The GCP credential is the JSON service key in stringified format.
|
|
||||||
|
|
||||||
#### Install Docker
|
|
||||||
|
|
||||||
The test suite also requires [Docker](https://www.docker.com/) and docker-compose to be installed on your laptop. Docker is used to set up a network with all of the elements required to test the jambonz-feature-server in a black-box type of fashion.
|
|
||||||
|
|
||||||
Once you have docker installed, you can optionally make sure everything Docker-wise is working properly by running this command from the project folder:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose -f test/docker-compose-testbed.yaml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
This may take several minutes to complete, mainly because the mysql schema needs to be installed and seeded, but if successful the output should look like this:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ docker-compose -f test/docker-compose-testbed.yaml up -d
|
|
||||||
Creating network "test_fs" with driver "bridge"
|
|
||||||
Creating test_webhook-transcribe_1 ... done
|
|
||||||
Creating test_webhook-decline_1 ... done
|
|
||||||
Creating test_mysql_1 ... done
|
|
||||||
Creating test_docker-host_1 ... done
|
|
||||||
Creating test_webhook-gather_1 ... done
|
|
||||||
Creating test_webhook-say_1 ... done
|
|
||||||
Creating test_freeswitch_1 ... done
|
|
||||||
Creating test_influxdb_1 ... done
|
|
||||||
Creating test_redis_1 ... done
|
|
||||||
Creating test_drachtio_1 ... done
|
|
||||||
```
|
|
||||||
|
|
||||||
At that point, you can run `docker ps` to see all of the containers running
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker ps
|
|
||||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
|
||||||
abbc3594f390 drachtio/drachtio-server:latest "/entrypoint.sh drac…" About a minute ago Up About a minute 0.0.0.0:9060->9022/tcp test_drachtio_1
|
|
||||||
1f384a274f87 redis:5-alpine "docker-entrypoint.s…" 2 minutes ago Up 2 minutes 0.0.0.0:16379->6379/tcp test_redis_1
|
|
||||||
78d0bb6ec9b1 influxdb:1.8 "/entrypoint.sh infl…" 2 minutes ago Up 2 minutes 0.0.0.0:8086->8086/tcp test_influxdb_1
|
|
||||||
9616ff790709 jambonz/webhook-test-scaffold:latest "/entrypoint.sh" 2 minutes ago Up 2 minutes 0.0.0.0:3102->3000/tcp test_webhook-gather_1
|
|
||||||
7323ab273ff4 drachtio/drachtio-freeswitch-mrf:v1.10.1-full "/entrypoint.sh free…" 2 minutes ago Up 2 minutes (healthy) 0.0.0.0:8022->8021/tcp test_freeswitch_1
|
|
||||||
e45e7d28dbc7 mysql:5.7 "docker-entrypoint.s…" 2 minutes ago Up 2 minutes (healthy) 33060/tcp, 0.0.0.0:3360->3306/tcp test_mysql_1
|
|
||||||
b626e5f3067e qoomon/docker-host "/entrypoint.sh" 2 minutes ago Up 2 minutes test_docker-host_1
|
|
||||||
b0a94b5e8941 jambonz/webhook-test-scaffold:latest "/entrypoint.sh" 2 minutes ago Up 2 minutes 0.0.0.0:3101->3000/tcp test_webhook-say_1
|
|
||||||
f80adda48eb5 jambonz/webhook-test-scaffold:latest "/entrypoint.sh" 2 minutes ago Up 2 minutes 0.0.0.0:3103->3000/tcp test_webhook-transcribe_1
|
|
||||||
223db4a9c670 jambonz/webhook-test-scaffold:latest "/entrypoint.sh" 2 minutes ago Up 2 minutes 0.0.0.0:3100->3000/tcp test_webhook-decline_1
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Run the regression test suite
|
|
||||||
|
|
||||||
The test suite has a dependency that the mysql client is installed on your laptop/machine where the test will be run. This is needed in order to seed the mysql database that is running in the docker network.
|
|
||||||
|
|
||||||
Assuming you have installed the mysql client, and done the above steps, you should now be able to run the tests:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./run-tests.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
If the docker network has not been started (as described above) it will start now, and this will take a minute or two. Otherwise, the test suite will start running immediately.
|
|
||||||
|
|
||||||
In evaluating the test results, be advised that the output is fairly verbose, and also in the process of shutting down once the tests are complete you will see a bunch of errors from redis (`@jambonz/realtimedb-helpers - redis error`). You can ignore these errors, they are just spit out by jambonz-feature-server as the test environment is torn down and it tries and fails to reconnect to redis.
|
|
||||||
|
|
||||||
The final output will indicate the number of tests run and passed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
1..28
|
|
||||||
# tests 28
|
|
||||||
# pass 28
|
|
||||||
|
|
||||||
# ok
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Adding your own tests
|
|
||||||
|
|
||||||
Running a successful regression test means you haven't broken anything - Great!
|
|
||||||
|
|
||||||
It doesn't, of course, mean that your shiny new feature or bugfix works. Adding a new test case to the suite is (unfortunately) non-trivial. We will add more documentation in the future with a how-to guide on that, but be advised it does require knowledge of the SIP protocol and the [SIPp](http://sipp.sourceforge.net/doc/reference.html) tool.
|
|
||||||
|
|
||||||
For now, if you are unable to add tests to the regression suite, please do test your feature as thoroughly as you can on your own jambonz cluster before giving us a pull request.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
212
lib/config.js
212
lib/config.js
@@ -1,212 +0,0 @@
|
|||||||
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');
|
|
||||||
if (process.env.JAMBONES_REDIS_SENTINELS) {
|
|
||||||
assert.ok(process.env.JAMBONES_REDIS_SENTINEL_MASTER_NAME,
|
|
||||||
'missing JAMBONES_REDIS_SENTINEL_MASTER_NAME env var, JAMBONES_REDIS_SENTINEL_PASSWORD env var is optional');
|
|
||||||
} else {
|
|
||||||
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 = parseInt(process.env.JAMBONES_MYSQL_REFRESH_TTL, 10) || 0;
|
|
||||||
const JAMBONES_MYSQL_CONNECTION_LIMIT = parseInt(process.env.JAMBONES_MYSQL_CONNECTION_LIMIT, 10) || 10;
|
|
||||||
|
|
||||||
/* 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 JAMBONES_WS_PING_INTERVAL_MS = parseInt(process.env.JAMBONES_WS_PING_INTERVAL_MS, 10) || 0;
|
|
||||||
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 HTTP_PROXY_IP = process.env.JAMBONES_HTTP_PROXY_IP;
|
|
||||||
const HTTP_PROXY_PORT = process.env.JAMBONES_HTTP_PROXY_PORT;
|
|
||||||
const HTTP_PROXY_PROTOCOL = process.env.JAMBONES_HTTP_PROXY_PROTOCOL || 'http';
|
|
||||||
|
|
||||||
const OPTIONS_PING_INTERVAL = parseInt(process.env.OPTIONS_PING_INTERVAL, 10) || 30000;
|
|
||||||
|
|
||||||
const JAMBONZ_RECORD_WS_BASE_URL = process.env.JAMBONZ_RECORD_WS_BASE_URL || process.env.JAMBONES_RECORD_WS_BASE_URL;
|
|
||||||
const JAMBONZ_RECORD_WS_USERNAME = process.env.JAMBONZ_RECORD_WS_USERNAME || process.env.JAMBONES_RECORD_WS_USERNAME;
|
|
||||||
const JAMBONZ_RECORD_WS_PASSWORD = process.env.JAMBONZ_RECORD_WS_PASSWORD || process.env.JAMBONES_RECORD_WS_PASSWORD;
|
|
||||||
const JAMBONZ_DISABLE_DIAL_PAI_HEADER = process.env.JAMBONZ_DISABLE_DIAL_PAI_HEADER || false;
|
|
||||||
const JAMBONES_DISABLE_DIRECT_P2P_CALL = process.env.JAMBONES_DISABLE_DIRECT_P2P_CALL || false;
|
|
||||||
|
|
||||||
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,
|
|
||||||
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,
|
|
||||||
HTTP_PROXY_IP,
|
|
||||||
HTTP_PROXY_PORT,
|
|
||||||
HTTP_PROXY_PROTOCOL,
|
|
||||||
OPTIONS_PING_INTERVAL,
|
|
||||||
RESPONSE_TIMEOUT_MS,
|
|
||||||
JAMBONES_WS_HANDSHAKE_TIMEOUT_MS,
|
|
||||||
JAMBONES_WS_MAX_PAYLOAD,
|
|
||||||
JAMBONES_WS_PING_INTERVAL_MS,
|
|
||||||
MAX_RECONNECTS,
|
|
||||||
GCP_JSON_KEY,
|
|
||||||
MICROSOFT_REGION,
|
|
||||||
MICROSOFT_API_KEY,
|
|
||||||
SONIOX_API_KEY,
|
|
||||||
DEEPGRAM_API_KEY,
|
|
||||||
JAMBONZ_RECORD_WS_BASE_URL,
|
|
||||||
JAMBONZ_RECORD_WS_USERNAME,
|
|
||||||
JAMBONZ_RECORD_WS_PASSWORD,
|
|
||||||
JAMBONZ_DISABLE_DIAL_PAI_HEADER,
|
|
||||||
JAMBONES_DISABLE_DIRECT_P2P_CALL
|
|
||||||
};
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
const appsMap = {
|
|
||||||
queue: {
|
|
||||||
// Dummy hook to follow later feature server logic.
|
|
||||||
call_hook: {
|
|
||||||
url: 'https://jambonz.org',
|
|
||||||
method: 'GET'
|
|
||||||
},
|
|
||||||
account_sid: '',
|
|
||||||
app_json: [{
|
|
||||||
verb: 'dequeue',
|
|
||||||
name: '',
|
|
||||||
timeout: 5
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
// Dummy hook to follow later feature server logic.
|
|
||||||
call_hook: {
|
|
||||||
url: 'https://jambonz.org',
|
|
||||||
method: 'GET'
|
|
||||||
},
|
|
||||||
account_sid: '',
|
|
||||||
app_json: [{
|
|
||||||
verb: 'dial',
|
|
||||||
callerId: '',
|
|
||||||
answerOnBridge: true,
|
|
||||||
target: [
|
|
||||||
{
|
|
||||||
type: 'user',
|
|
||||||
name: ''
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createJambonzApp = (type, {account_sid, name, caller_id}) => {
|
|
||||||
const app = {...appsMap[type]};
|
|
||||||
app.account_sid = account_sid;
|
|
||||||
switch (type) {
|
|
||||||
case 'queue':
|
|
||||||
app.app_json[0].name = name;
|
|
||||||
break;
|
|
||||||
case 'user':
|
|
||||||
app.app_json[0].callerId = caller_id;
|
|
||||||
app.app_json[0].target[0].name = name;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
app.app_json = JSON.stringify(app.app_json);
|
|
||||||
return app;
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
createJambonzApp
|
|
||||||
};
|
|
||||||
@@ -3,339 +3,139 @@ const makeTask = require('../../tasks/make_task');
|
|||||||
const RestCallSession = require('../../session/rest-call-session');
|
const RestCallSession = require('../../session/rest-call-session');
|
||||||
const CallInfo = require('../../session/call-info');
|
const CallInfo = require('../../session/call-info');
|
||||||
const {CallDirection, CallStatus} = require('../../utils/constants');
|
const {CallDirection, CallStatus} = require('../../utils/constants');
|
||||||
const uuidv4 = require('uuid-random');
|
|
||||||
const SipError = require('drachtio-srf').SipError;
|
const SipError = require('drachtio-srf').SipError;
|
||||||
const { validationResult, body } = require('express-validator');
|
|
||||||
const { validate } = require('@jambonz/verb-specifications');
|
|
||||||
const sysError = require('./error');
|
const sysError = require('./error');
|
||||||
const HttpRequestor = require('../../utils/http-requestor');
|
const Requestor = require('../../utils/requestor');
|
||||||
const WsRequestor = require('../../utils/ws-requestor');
|
|
||||||
const RootSpan = require('../../utils/call-tracer');
|
|
||||||
const dbUtils = require('../../utils/db-utils');
|
|
||||||
const { mergeSdpMedia, extractSdpMedia } = require('../../utils/sdp-utils');
|
|
||||||
const { createCallSchema, customSanitizeFunction } = require('../schemas/create-call');
|
|
||||||
|
|
||||||
const removeNullProperties = (obj) => (Object.keys(obj).forEach((key) => obj[key] === null && delete obj[key]), obj);
|
router.post('/', async(req, res) => {
|
||||||
const removeNulls = (req, res, next) => {
|
const {logger} = req.app.locals;
|
||||||
req.body = removeNullProperties(req.body);
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
|
|
||||||
router.post('/',
|
logger.debug({body: req.body}, 'got createCall request');
|
||||||
removeNulls,
|
try {
|
||||||
createCallSchema,
|
let uri, cs, to;
|
||||||
body('tag').custom((value) => {
|
const restDial = makeTask(logger, {'rest:dial': req.body});
|
||||||
if (value) {
|
|
||||||
customSanitizeFunction(value);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}),
|
|
||||||
async(req, res) => {
|
|
||||||
const {logger} = req.app.locals;
|
|
||||||
const errors = validationResult(req);
|
|
||||||
if (!errors.isEmpty()) {
|
|
||||||
logger.info({errors: errors.array()}, 'POST /Calls: validation errors');
|
|
||||||
return res.status(400).json({ errors: errors.array() });
|
|
||||||
}
|
|
||||||
const accountSid = req.body.account_sid;
|
|
||||||
const {srf} = require('../../..');
|
const {srf} = require('../../..');
|
||||||
|
const {getSBC, getFreeswitch} = srf.locals;
|
||||||
|
const sbcAddress = getSBC();
|
||||||
|
if (!sbcAddress) throw new Error('no available SBCs for outbound call creation');
|
||||||
|
const target = restDial.to;
|
||||||
|
const opts = {
|
||||||
|
callingNumber: restDial.from,
|
||||||
|
headers: req.body.headers || {}
|
||||||
|
};
|
||||||
|
|
||||||
const app_json = req.body['app_json'];
|
switch (target.type) {
|
||||||
try {
|
case 'phone':
|
||||||
// app_json is created only by api-server.
|
uri = `sip:${target.number}@${sbcAddress}`;
|
||||||
if (app_json) {
|
to = target.number;
|
||||||
// if available, delete from req before creating task
|
break;
|
||||||
delete req.body.app_json;
|
case 'user':
|
||||||
// validate possible app_json via verb-specifications
|
uri = `sip:${target.name}`;
|
||||||
validate(logger, JSON.parse(app_json));
|
to = target.name;
|
||||||
}
|
break;
|
||||||
} catch (err) {
|
case 'sip':
|
||||||
logger.debug({ err }, `invalid app_json: ${err.message}`);
|
uri = target.sipUri;
|
||||||
|
to = uri;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug({body: req.body}, 'got createCall request');
|
/* create endpoint for outdial */
|
||||||
try {
|
const ms = getFreeswitch();
|
||||||
let uri, cs, to;
|
if (!ms) throw new Error('no available Freeswitch for outbound call creation');
|
||||||
|
const ep = await ms.createEndpoint();
|
||||||
|
logger.debug(`createCall: successfully allocated endpoint, sending INVITE to ${sbcAddress}`);
|
||||||
|
|
||||||
const restDial = makeTask(logger, { 'rest:dial': req.body });
|
/* launch outdial */
|
||||||
restDial.appJson = app_json;
|
let sdp, sipLogger;
|
||||||
|
const connectStream = async(remoteSdp) => {
|
||||||
const {lookupAccountDetails, lookupCarrierByPhoneNumber, lookupCarrier} = dbUtils(logger, srf);
|
if (remoteSdp !== sdp) {
|
||||||
const {
|
ep.modify(sdp = remoteSdp);
|
||||||
lookupAppBySid
|
return true;
|
||||||
} = srf.locals.dbHelpers;
|
|
||||||
const {getSBC, getFreeswitch} = srf.locals;
|
|
||||||
const sbcAddress = getSBC();
|
|
||||||
if (!sbcAddress) throw new Error('no available SBCs for outbound call creation');
|
|
||||||
const target = restDial.to;
|
|
||||||
const opts = {
|
|
||||||
callingNumber: restDial.from,
|
|
||||||
...(restDial.callerName && {callingName: restDial.callerName}),
|
|
||||||
headers: req.body.headers || {}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const {lookupTeamsByAccount, lookupAccountBySid} = srf.locals.dbHelpers;
|
|
||||||
const account = await lookupAccountBySid(req.body.account_sid);
|
|
||||||
const accountInfo = await lookupAccountDetails(req.body.account_sid);
|
|
||||||
const callSid = uuidv4();
|
|
||||||
const application = req.body.application_sid ? await lookupAppBySid(req.body.application_sid) : null;
|
|
||||||
const record_all_calls = account.record_all_calls || (application && application.record_all_calls);
|
|
||||||
const recordOutputFormat = account.record_format || 'mp3';
|
|
||||||
const rootSpan = new RootSpan('rest-call', {
|
|
||||||
callSid,
|
|
||||||
accountSid,
|
|
||||||
...(req.body?.application_sid && {'X-Application-Sid': req.body.application_sid})
|
|
||||||
});
|
|
||||||
|
|
||||||
opts.headers = {
|
|
||||||
...opts.headers,
|
|
||||||
'X-Jambonz-Routing': target.type,
|
|
||||||
'X-Jambonz-FS-UUID': srf.locals.fsUUID,
|
|
||||||
'X-Call-Sid': callSid,
|
|
||||||
'X-Account-Sid': accountSid,
|
|
||||||
'X-Trace-ID': rootSpan.traceId,
|
|
||||||
...(req.body?.application_sid && {'X-Application-Sid': req.body.application_sid}),
|
|
||||||
...(restDial.fromHost && {'X-Preferred-From-Host': restDial.fromHost}),
|
|
||||||
...(record_all_calls && {'X-Record-All-Calls': recordOutputFormat})
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (target.type) {
|
|
||||||
case 'phone':
|
|
||||||
case 'teams':
|
|
||||||
uri = `sip:${target.number}@${sbcAddress}`;
|
|
||||||
to = target.number;
|
|
||||||
if ('teams' === target.type) {
|
|
||||||
const obj = await lookupTeamsByAccount(accountSid);
|
|
||||||
if (!obj) throw new Error('dial to ms teams not allowed; account must first be configured with teams info');
|
|
||||||
Object.assign(opts.headers, {
|
|
||||||
'X-MS-Teams-FQDN': obj.ms_teams_fqdn,
|
|
||||||
'X-MS-Teams-Tenant-FQDN': target.tenant || obj.tenant_fqdn
|
|
||||||
});
|
|
||||||
if (target.vmail === true) uri = `${uri};opaque=app:voicemail`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'user':
|
|
||||||
uri = `sip:${target.name}`;
|
|
||||||
to = target.name;
|
|
||||||
if (target.overrideTo) {
|
|
||||||
Object.assign(opts.headers, {
|
|
||||||
'X-Override-To': target.overrideTo
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'sip':
|
|
||||||
uri = target.sipUri;
|
|
||||||
to = uri;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
if (target.type === 'phone' && target.trunk) {
|
};
|
||||||
const voip_carrier_sid = await lookupCarrier(req.body.account_sid, target.trunk);
|
Object.assign(opts, {
|
||||||
logger.info(
|
proxy: `sip:${sbcAddress}`,
|
||||||
`createCall: selected ${voip_carrier_sid} for requested carrier: ${target.trunk || 'unspecified'})`);
|
localSdp: ep.local.sdp
|
||||||
if (voip_carrier_sid) {
|
});
|
||||||
opts.headers['X-Requested-Carrier-Sid'] = voip_carrier_sid;
|
if (target.auth) opts.auth = this.target.auth;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* trunk isn't specified,
|
|
||||||
* check if from-number matches any existing numbers on Jambonz
|
|
||||||
* */
|
|
||||||
if (target.type === 'phone' && !target.trunk) {
|
|
||||||
const str = restDial.from || '';
|
|
||||||
const callingNumber = str.startsWith('+') ? str.substring(1) : str;
|
|
||||||
const voip_carrier_sid = await lookupCarrierByPhoneNumber(req.body.account_sid, callingNumber);
|
|
||||||
logger.info(
|
|
||||||
`createCall: selected ${voip_carrier_sid} for requested phone number: ${callingNumber || 'unspecified'})`);
|
|
||||||
if (voip_carrier_sid) {
|
|
||||||
opts.headers['X-Requested-Carrier-Sid'] = voip_carrier_sid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* create endpoint for outdial */
|
|
||||||
const ms = getFreeswitch();
|
|
||||||
if (!ms) throw new Error('no available Freeswitch for outbound call creation');
|
|
||||||
const ep = await ms.createEndpoint();
|
|
||||||
logger.debug(`createCall: successfully allocated endpoint, sending INVITE to ${sbcAddress}`);
|
|
||||||
|
|
||||||
/* launch outdial */
|
|
||||||
let sdp, sipLogger;
|
|
||||||
let dualEp;
|
|
||||||
let localSdp = ep.local.sdp;
|
|
||||||
|
|
||||||
if (req.body.dual_streams) {
|
|
||||||
dualEp = await ms.createEndpoint();
|
|
||||||
localSdp = mergeSdpMedia(localSdp, dualEp.local.sdp);
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectStream = async(remoteSdp) => {
|
|
||||||
if (remoteSdp !== sdp) {
|
|
||||||
sdp = remoteSdp;
|
|
||||||
if (req.body.dual_streams) {
|
|
||||||
const [sdpLegA, sdpLebB] = extractSdpMedia(remoteSdp);
|
|
||||||
|
|
||||||
await ep.modify(sdpLegA);
|
|
||||||
await dualEp.modify(sdpLebB);
|
|
||||||
await ep.bridge(dualEp);
|
|
||||||
} else {
|
|
||||||
ep.modify(sdp);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
Object.assign(opts, {
|
|
||||||
proxy: `sip:${sbcAddress}`,
|
|
||||||
localSdp
|
|
||||||
});
|
|
||||||
if (target.auth) opts.auth = target.auth;
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* create our application object -
|
* create our application object -
|
||||||
* not from the database as per an inbound call,
|
* not from the database as per an inbound call,
|
||||||
* but from the provided params in the request
|
* but from the provided params in the request
|
||||||
*/
|
*/
|
||||||
const app = req.body;
|
const app = req.body;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* attach our requestor and notifier objects
|
* attach our requestor and notifier objects
|
||||||
* these will be used for all http requests we make during this call
|
* these will be used for all http requests we make during this call
|
||||||
*/
|
*/
|
||||||
if ('WS' === app.call_hook?.method || /^wss?:/.test(app.call_hook.url)) {
|
app.requestor = new Requestor(logger, app.call_hook);
|
||||||
logger.debug({call_hook: app.call_hook}, 'creating websocket for call hook');
|
if (app.call_status_hook) app.notifier = new Requestor(logger, app.call_status_hook);
|
||||||
app.requestor = new WsRequestor(logger, account.account_sid, app.call_hook, account.webhook_secret) ;
|
else app.notifier = {request: () => {}};
|
||||||
if (app.call_hook.url === app.call_status_hook.url || !app.call_status_hook?.url) {
|
|
||||||
logger.debug('reusing websocket for call status hook');
|
/* now launch the outdial */
|
||||||
app.notifier = app.requestor;
|
try {
|
||||||
|
const dlg = await srf.createUAC(uri, opts, {
|
||||||
|
cbRequest: (err, inviteReq) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error(err, 'createCall Error creating call');
|
||||||
|
res.status(500).send('Call Failure');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* ok our outbound INVITE is in flight */
|
||||||
|
|
||||||
|
const tasks = [restDial];
|
||||||
|
const callInfo = new CallInfo({
|
||||||
|
direction: CallDirection.Outbound,
|
||||||
|
req: inviteReq,
|
||||||
|
to,
|
||||||
|
tag: app.tag,
|
||||||
|
accountSid: req.body.account_sid,
|
||||||
|
applicationSid: app.application_sid
|
||||||
|
});
|
||||||
|
cs = new RestCallSession({logger, application: app, srf, req: inviteReq, ep, tasks, callInfo});
|
||||||
|
cs.exec(req);
|
||||||
|
|
||||||
|
res.status(201).json({sid: cs.callSid});
|
||||||
|
|
||||||
|
sipLogger = logger.child({
|
||||||
|
callSid: cs.callSid,
|
||||||
|
callId: callInfo.callId
|
||||||
|
});
|
||||||
|
sipLogger.info(`outbound REST call attempt to ${JSON.stringify(target)} has been sent`);
|
||||||
|
},
|
||||||
|
cbProvisional: (prov) => {
|
||||||
|
const callStatus = prov.body ? CallStatus.EarlyMedia : CallStatus.Ringing;
|
||||||
|
if ([180, 183].includes(prov.status) && prov.body) connectStream(prov.body);
|
||||||
|
restDial.emit('callStatus', prov.status, !!prov.body);
|
||||||
|
cs.emit('callStatusChange', {callStatus, sipStatus: prov.status});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
connectStream(dlg.remote.sdp);
|
||||||
|
cs.emit('callStatusChange', {callStatus: CallStatus.InProgress, sipStatus: 200});
|
||||||
|
restDial.emit('callStatus', 200);
|
||||||
|
restDial.emit('connect', dlg);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
let callStatus = CallStatus.Failed;
|
||||||
|
if (err instanceof SipError) {
|
||||||
|
if ([486, 603].includes(err.status)) callStatus = CallStatus.Busy;
|
||||||
|
else if (487 === err.status) callStatus = CallStatus.NoAnswer;
|
||||||
|
sipLogger.info(`REST outdial failed with ${err.status}`);
|
||||||
|
cs.emit('callStatusChange', {callStatus, sipStatus: err.status});
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
logger.debug({call_hook: app.call_hook}, 'creating http client for call hook');
|
cs.emit('callStatusChange', {callStatus, sipStatus: 500});
|
||||||
app.requestor = new HttpRequestor(logger, account.account_sid, app.call_hook, account.webhook_secret);
|
sipLogger.error({err}, 'REST outdial failed');
|
||||||
}
|
}
|
||||||
if (!app.notifier && app.call_status_hook) {
|
ep.destroy();
|
||||||
app.notifier = new HttpRequestor(logger, account.account_sid, app.call_status_hook, account.webhook_secret);
|
|
||||||
logger.debug({call_hook: app.call_hook}, 'creating http client for call status hook');
|
|
||||||
}
|
|
||||||
else if (!app.notifier) {
|
|
||||||
logger.debug('creating null call status hook');
|
|
||||||
app.notifier = {request: () => {}, close: () => {}};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* now launch the outdial */
|
|
||||||
try {
|
|
||||||
const dlg = await srf.createUAC(uri, {...opts, followRedirects: true, keepUriOnRedirect: true}, {
|
|
||||||
cbRequest: (err, inviteReq) => {
|
|
||||||
/* in case of 302 redirect, this gets called twice, ignore the second
|
|
||||||
except to update the req so that it can later be canceled if need be
|
|
||||||
*/
|
|
||||||
if (res.headersSent) {
|
|
||||||
logger.info(`create-call: got redirect, updating request to new call-id ${req.get('Call-ID')}`);
|
|
||||||
if (cs) cs.req = inviteReq;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (err) {
|
|
||||||
logger.error(err, 'createCall Error creating call');
|
|
||||||
res.status(500).send('Call Failure');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
inviteReq.srf = srf;
|
|
||||||
inviteReq.locals = {
|
|
||||||
...(inviteReq || {}),
|
|
||||||
callSid,
|
|
||||||
application_sid: app.application_sid
|
|
||||||
};
|
|
||||||
/* ok our outbound INVITE is in flight */
|
|
||||||
|
|
||||||
const tasks = [restDial];
|
|
||||||
sipLogger = logger.child({
|
|
||||||
callSid,
|
|
||||||
callId: inviteReq.get('Call-ID'),
|
|
||||||
accountSid,
|
|
||||||
traceId: rootSpan.traceId
|
|
||||||
});
|
|
||||||
app.requestor.logger = app.notifier.logger = sipLogger;
|
|
||||||
const callInfo = new CallInfo({
|
|
||||||
direction: CallDirection.Outbound,
|
|
||||||
req: inviteReq,
|
|
||||||
to,
|
|
||||||
tag: app.tag,
|
|
||||||
callSid,
|
|
||||||
accountSid: req.body.account_sid,
|
|
||||||
applicationSid: app.application_sid,
|
|
||||||
traceId: rootSpan.traceId
|
|
||||||
});
|
|
||||||
cs = new RestCallSession({
|
|
||||||
logger: sipLogger,
|
|
||||||
application: app,
|
|
||||||
srf,
|
|
||||||
req: inviteReq,
|
|
||||||
ep,
|
|
||||||
ep2: dualEp,
|
|
||||||
tasks,
|
|
||||||
callInfo,
|
|
||||||
accountInfo,
|
|
||||||
rootSpan
|
|
||||||
});
|
|
||||||
cs.exec(req);
|
|
||||||
|
|
||||||
res.status(201).json({sid: cs.callSid, callId: inviteReq.get('Call-ID')});
|
|
||||||
|
|
||||||
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;
|
|
||||||
if ([180, 183].includes(prov.status) && prov.body) connectStream(prov.body);
|
|
||||||
restDial.emit('callStatus', prov.status, !!prov.body);
|
|
||||||
cs.emit('callStatusChange', {callStatus, sipStatus: prov.status});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
connectStream(dlg.remote.sdp);
|
|
||||||
cs.emit('callStatusChange', {
|
|
||||||
callStatus: CallStatus.InProgress,
|
|
||||||
sipStatus: 200,
|
|
||||||
sipReason: 'OK'
|
|
||||||
});
|
|
||||||
restDial.emit('callStatus', 200);
|
|
||||||
restDial.emit('connect', dlg);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
let callStatus = CallStatus.Failed;
|
|
||||||
if (err instanceof SipError) {
|
|
||||||
if ([486, 603].includes(err.status)) callStatus = CallStatus.Busy;
|
|
||||||
else if (487 === err.status) callStatus = CallStatus.NoAnswer;
|
|
||||||
if (sipLogger) sipLogger.info(`REST outdial failed with ${err.status}`);
|
|
||||||
else console.log(`REST outdial failed with ${err.status}`);
|
|
||||||
if (cs) cs.emit('callStatusChange', {
|
|
||||||
callStatus,
|
|
||||||
sipStatus: err.status,
|
|
||||||
sipReason: err.reason
|
|
||||||
});
|
|
||||||
cs.callGone = true;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
if (cs) cs.emit('callStatusChange', {
|
|
||||||
callStatus,
|
|
||||||
sipStatus: 500,
|
|
||||||
sipReason: 'Internal Server Error'
|
|
||||||
});
|
|
||||||
if (sipLogger) sipLogger.error({err}, 'REST outdial failed');
|
|
||||||
else console.error(err);
|
|
||||||
}
|
|
||||||
ep.destroy();
|
|
||||||
if (dualEp) {
|
|
||||||
dualEp.destroy();
|
|
||||||
}
|
|
||||||
setTimeout(restDial.kill.bind(restDial, cs), 5000);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
sysError(logger, res, err);
|
|
||||||
}
|
}
|
||||||
});
|
} catch (err) {
|
||||||
|
sysError(logger, res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
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('@jambonz/verb-specifications');
|
|
||||||
const makeTask = require('../../tasks/make_task');
|
|
||||||
|
|
||||||
router.post('/:sid', async(req, res) => {
|
|
||||||
const {logger} = req.app.locals;
|
|
||||||
const {srf} = req.app.locals;
|
|
||||||
const {message_sid, account_sid} = req.body;
|
|
||||||
|
|
||||||
logger.debug({body: req.body}, 'got createMessage request');
|
|
||||||
|
|
||||||
const data = [{
|
|
||||||
verb: 'message',
|
|
||||||
...req.body
|
|
||||||
}];
|
|
||||||
delete data[0].message_sid;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tasks = normalizeJambones(logger, data)
|
|
||||||
.map((tdata) => makeTask(logger, tdata));
|
|
||||||
|
|
||||||
const callInfo = new CallInfo({
|
|
||||||
direction: CallDirection.None,
|
|
||||||
messageSid: message_sid,
|
|
||||||
accountSid: account_sid,
|
|
||||||
res
|
|
||||||
});
|
|
||||||
const cs = new SmsSession({logger, srf, tasks, callInfo});
|
|
||||||
cs.exec();
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({err, body: req.body}, 'OutboundSMS: error launching SmsCallSession');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -7,13 +7,11 @@ const {DbErrorUnprocessableRequest} = require('../utils/errors');
|
|||||||
/**
|
/**
|
||||||
* validate the call state
|
* validate the call state
|
||||||
*/
|
*/
|
||||||
function retrieveCallSession(logger, callSid, opts) {
|
function retrieveCallSession(callSid, opts) {
|
||||||
logger.debug(`retrieving session for callSid ${callSid}`);
|
|
||||||
const cs = sessionTracker.get(callSid);
|
const cs = sessionTracker.get(callSid);
|
||||||
if (cs) {
|
if (cs) {
|
||||||
const task = cs.currentTask;
|
const task = cs.currentTask;
|
||||||
if (!task || task.name != TaskName.Enqueue) {
|
if (!task || task.name != TaskName.Enqueue) {
|
||||||
logger.debug({cs}, 'found call session but not in Enqueue task??');
|
|
||||||
throw new DbErrorUnprocessableRequest(`enqueue api failure: indicated call is not queued: ${task.name}`);
|
throw new DbErrorUnprocessableRequest(`enqueue api failure: indicated call is not queued: ${task.name}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,14 +19,14 @@ function retrieveCallSession(logger, callSid, opts) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* notify a waiting session that a queue event has occurred
|
* notify a waiting session that a conference has started
|
||||||
*/
|
*/
|
||||||
router.post('/:callSid', async(req, res) => {
|
router.post('/:callSid', async(req, res) => {
|
||||||
const logger = req.app.locals.logger;
|
const logger = req.app.locals.logger;
|
||||||
const callSid = req.params.callSid;
|
const callSid = req.params.callSid;
|
||||||
logger.debug({callSid, body: req.body}, 'got enqueue event');
|
logger.debug({body: req.body}, 'got enqueue event');
|
||||||
try {
|
try {
|
||||||
const cs = retrieveCallSession(logger, callSid, req.body);
|
const cs = retrieveCallSession(callSid, req.body);
|
||||||
if (!cs) {
|
if (!cs) {
|
||||||
logger.info(`enqueue: callSid not found ${callSid}`);
|
logger.info(`enqueue: callSid not found ${callSid}`);
|
||||||
return res.sendStatus(404);
|
return res.sendStatus(404);
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ api.use('/conference', require('./conference'));
|
|||||||
api.use('/dequeue', require('./dequeue'));
|
api.use('/dequeue', require('./dequeue'));
|
||||||
api.use('/enqueue', require('./enqueue'));
|
api.use('/enqueue', require('./enqueue'));
|
||||||
|
|
||||||
api.use('/messaging', require('./messaging')); // inbound SMS
|
// health checks
|
||||||
api.use('/createMessage', require('./create-message')); // outbound SMS (REST)
|
api.get('/', (req, res) => res.sendStatus(200));
|
||||||
|
api.get('/health', (req, res) => res.sendStatus(200));
|
||||||
|
|
||||||
module.exports = api;
|
module.exports = api;
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
const router = require('express').Router();
|
|
||||||
const HttpRequestor = require('../../utils/http-requestor');
|
|
||||||
const WsRequestor = require('../../utils/ws-requestor');
|
|
||||||
const CallInfo = require('../../session/call-info');
|
|
||||||
const {CallDirection} = require('../../utils/constants');
|
|
||||||
const SmsSession = require('../../session/sms-call-session');
|
|
||||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
|
||||||
const {TaskPreconditions} = require('../../utils/constants');
|
|
||||||
const makeTask = require('../../tasks/make_task');
|
|
||||||
|
|
||||||
router.post('/:partner', async(req, res) => {
|
|
||||||
const {logger} = req.app.locals;
|
|
||||||
|
|
||||||
logger.debug({body: req.body}, `got incomingSms request from partner ${req.params.partner}`);
|
|
||||||
|
|
||||||
let tasks;
|
|
||||||
const {srf} = require('../../..');
|
|
||||||
const {lookupAccountBySid} = srf.locals.dbHelpers;
|
|
||||||
const app = req.body.app;
|
|
||||||
const account = await lookupAccountBySid(app.accountSid);
|
|
||||||
const hook = app.messaging_hook;
|
|
||||||
let requestor;
|
|
||||||
|
|
||||||
if ('WS' === hook?.method) {
|
|
||||||
app.requestor = new WsRequestor(logger, account.account_sid, hook, account.webhook_secret) ;
|
|
||||||
app.notifier = app.requestor;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
app.requestor = new HttpRequestor(logger, account.account_sid, hook, account.webhook_secret);
|
|
||||||
app.notifier = {request: () => {}};
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
carrier: req.params.partner,
|
|
||||||
messageSid: app.messageSid,
|
|
||||||
accountSid: app.accountSid,
|
|
||||||
serviceProviderSid: account.service_provider_sid,
|
|
||||||
applicationSid: app.applicationSid,
|
|
||||||
from: req.body.from,
|
|
||||||
to: req.body.to,
|
|
||||||
cc: req.body.cc,
|
|
||||||
text: req.body.text,
|
|
||||||
media: req.body.media
|
|
||||||
};
|
|
||||||
res.status(200).json({sid: req.body.messageSid});
|
|
||||||
|
|
||||||
try {
|
|
||||||
tasks = await requestor.request('session:new', hook, payload);
|
|
||||||
logger.info({tasks}, 'response from incoming SMS webhook');
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({err, hook}, 'Error sending incoming SMS message');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// process any verbs in response
|
|
||||||
if (Array.isArray(tasks) && tasks.length) {
|
|
||||||
const {srf} = req.app.locals;
|
|
||||||
|
|
||||||
app.requestor = requestor;
|
|
||||||
app.notifier = {request: () => {}};
|
|
||||||
|
|
||||||
try {
|
|
||||||
tasks = normalizeJambones(logger, tasks)
|
|
||||||
.map((tdata) => makeTask(logger, tdata))
|
|
||||||
.filter((t) => t.preconditions === TaskPreconditions.None);
|
|
||||||
|
|
||||||
if (0 === tasks.length) {
|
|
||||||
logger.info('inboundSMS: after removing invalid verbs there are no tasks left to execute');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const callInfo = new CallInfo({
|
|
||||||
direction: CallDirection.None,
|
|
||||||
messageSid: app.messageSid,
|
|
||||||
accountSid: app.accountSid,
|
|
||||||
applicationSid: app.applicationSid
|
|
||||||
});
|
|
||||||
const cs = new SmsSession({logger, srf, application: app, tasks, callInfo});
|
|
||||||
cs.exec();
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({err, tasks}, 'InboundSMS: error launching SmsCallSession');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -9,29 +9,22 @@ const {CallStatus, CallDirection} = require('../../utils/constants');
|
|||||||
*/
|
*/
|
||||||
function retrieveCallSession(callSid, opts) {
|
function retrieveCallSession(callSid, opts) {
|
||||||
if (opts.call_status_hook && !opts.call_hook) {
|
if (opts.call_status_hook && !opts.call_hook) {
|
||||||
throw new DbErrorBadRequest(
|
throw new DbErrorBadRequest('call_status_hook can be updated only when call_hook is also being updated');
|
||||||
`call_status_hook can be updated only when call_hook is also being updated for call_sid ${callSid}`);
|
|
||||||
}
|
}
|
||||||
const cs = sessionTracker.get(callSid);
|
const cs = sessionTracker.get(callSid);
|
||||||
if (!cs) {
|
|
||||||
throw new DbErrorUnprocessableRequest(`call session is gone for call_sid ${callSid}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.call_status === CallStatus.Completed && !cs.hasStableDialog) {
|
if (opts.call_status === CallStatus.Completed && !cs.hasStableDialog) {
|
||||||
throw new DbErrorUnprocessableRequest(
|
throw new DbErrorUnprocessableRequest('current call state is incompatible with requested action');
|
||||||
`current call state is incompatible with requested action for call_sid ${callSid}`);
|
|
||||||
}
|
}
|
||||||
else if (opts.call_status === CallStatus.NoAnswer) {
|
else if (opts.call_status === CallStatus.NoAnswer) {
|
||||||
if (cs.direction === CallDirection.Outbound) {
|
if (cs.direction === CallDirection.Outbound) {
|
||||||
if (!cs.isOutboundCallRinging) {
|
if (!cs.isOutboundCallRinging) {
|
||||||
throw new DbErrorUnprocessableRequest(
|
throw new DbErrorUnprocessableRequest('current call state is incompatible with requested action');
|
||||||
`current call state is incompatible with requested action for call_sid ${callSid}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
if (cs.isInboundCallAnswered) {
|
if (cs.isInboundCallAnswered) {
|
||||||
throw new DbErrorUnprocessableRequest(
|
throw new DbErrorUnprocessableRequest('current call state is incompatible with requested action');
|
||||||
`current call state is incompatible with requested action for call_sid ${callSid}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,25 +38,15 @@ function retrieveCallSession(callSid, opts) {
|
|||||||
router.post('/:callSid', async(req, res) => {
|
router.post('/:callSid', async(req, res) => {
|
||||||
const logger = req.app.locals.logger;
|
const logger = req.app.locals.logger;
|
||||||
const callSid = req.params.callSid;
|
const callSid = req.params.callSid;
|
||||||
logger.debug({body: req.body}, 'got updateCall request');
|
logger.debug({body: req.body}, 'got upateCall request');
|
||||||
try {
|
try {
|
||||||
const cs = retrieveCallSession(callSid, req.body);
|
const cs = retrieveCallSession(callSid, req.body);
|
||||||
if (!cs) {
|
if (!cs) {
|
||||||
logger.info(`updateCall: callSid not found ${callSid}`);
|
logger.info(`updateCall: callSid not found ${callSid}`);
|
||||||
return res.sendStatus(404);
|
return res.sendStatus(404);
|
||||||
}
|
}
|
||||||
|
res.sendStatus(202);
|
||||||
if (req.body.sip_request) {
|
cs.updateCall(req.body, callSid);
|
||||||
const response = await cs.updateCall(req.body, callSid);
|
|
||||||
res.status(200).json({
|
|
||||||
status: response.status,
|
|
||||||
reason: response.reason
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
res.sendStatus(202);
|
|
||||||
cs.updateCall(req.body, callSid);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sysError(logger, res, err);
|
sysError(logger, res, err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const api = require('./api');
|
const api = require('./api');
|
||||||
const routes = express.Router();
|
const routes = express.Router();
|
||||||
const sessionTracker = require('../session/session-tracker');
|
|
||||||
|
|
||||||
const readiness = (req, res) => {
|
|
||||||
const logger = req.app.locals.logger;
|
|
||||||
const {count} = sessionTracker;
|
|
||||||
const {srf} = require('../..');
|
|
||||||
const {getFreeswitch} = srf.locals;
|
|
||||||
if (getFreeswitch()) {
|
|
||||||
return res.status(200).json({calls: count});
|
|
||||||
}
|
|
||||||
logger.info('responding to /health check with failure as freeswitch is not up');
|
|
||||||
res.sendStatus(480);
|
|
||||||
};
|
|
||||||
|
|
||||||
routes.use('/v1', api);
|
routes.use('/v1', api);
|
||||||
|
|
||||||
// health check
|
// health checks
|
||||||
routes.get('/health', readiness);
|
routes.get('/', (req, res) => {
|
||||||
|
res.sendStatus(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.get('/health', (req, res) => {
|
||||||
|
res.sendStatus(200);
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = routes;
|
module.exports = routes;
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
const { checkSchema } = require('express-validator');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @path api-server {{base_url}}/v1/Accounts/:account_sid/Calls
|
|
||||||
* @see https://api.jambonz.org/#243a2edd-7999-41db-bd0d-08082bbab401
|
|
||||||
*/
|
|
||||||
const createCallSchema = checkSchema({
|
|
||||||
application_sid: {
|
|
||||||
isString: true,
|
|
||||||
optional: true,
|
|
||||||
isLength: { options: { min: 36, max: 36 } },
|
|
||||||
errorMessage: 'Invalid application_sid',
|
|
||||||
},
|
|
||||||
answerOnBridge: {
|
|
||||||
isBoolean: true,
|
|
||||||
optional: true,
|
|
||||||
errorMessage: 'Invalid answerOnBridge',
|
|
||||||
},
|
|
||||||
from: {
|
|
||||||
errorMessage: 'Invalid from',
|
|
||||||
isString: true,
|
|
||||||
isLength: {
|
|
||||||
options: { min: 1, max: 256 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fromHost: {
|
|
||||||
isString: true,
|
|
||||||
optional: true,
|
|
||||||
errorMessage: 'Invalid fromHost',
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
errorMessage: 'Invalid to',
|
|
||||||
isObject: true,
|
|
||||||
},
|
|
||||||
callerName: {
|
|
||||||
isString: true,
|
|
||||||
optional: true,
|
|
||||||
errorMessage: 'Invalid callerName',
|
|
||||||
},
|
|
||||||
amd: {
|
|
||||||
isObject: true,
|
|
||||||
optional: true,
|
|
||||||
},
|
|
||||||
tag: {
|
|
||||||
isObject: true,
|
|
||||||
optional: true,
|
|
||||||
errorMessage: 'Invalid tag',
|
|
||||||
},
|
|
||||||
app_json: {
|
|
||||||
isString: true,
|
|
||||||
optional: true,
|
|
||||||
errorMessage: 'Invalid app_json',
|
|
||||||
},
|
|
||||||
account_sid: {
|
|
||||||
isString: true,
|
|
||||||
optional: true,
|
|
||||||
errorMessage: 'Invalid account_sid',
|
|
||||||
isLength: { options: { min: 36, max: 36 } },
|
|
||||||
},
|
|
||||||
timeout: {
|
|
||||||
isInt: true,
|
|
||||||
optional: true,
|
|
||||||
errorMessage: 'Invalid timeout',
|
|
||||||
},
|
|
||||||
timeLimit: {
|
|
||||||
isInt: true,
|
|
||||||
optional: true,
|
|
||||||
errorMessage: 'Invalid timeLimit',
|
|
||||||
},
|
|
||||||
call_hook: {
|
|
||||||
isObject: true,
|
|
||||||
optional: true,
|
|
||||||
errorMessage: 'Invalid call_hook',
|
|
||||||
},
|
|
||||||
call_status_hook: {
|
|
||||||
isObject: true,
|
|
||||||
optional: true,
|
|
||||||
errorMessage: 'Invalid call_status_hook',
|
|
||||||
},
|
|
||||||
speech_synthesis_vendor: {
|
|
||||||
isString: true,
|
|
||||||
optional: true,
|
|
||||||
errorMessage: 'Invalid speech_synthesis_vendor',
|
|
||||||
},
|
|
||||||
speech_synthesis_language: {
|
|
||||||
isString: true,
|
|
||||||
optional: true,
|
|
||||||
errorMessage: 'Invalid speech_synthesis_language',
|
|
||||||
},
|
|
||||||
speech_synthesis_voice: {
|
|
||||||
isString: true,
|
|
||||||
optional: true,
|
|
||||||
errorMessage: 'Invalid speech_synthesis_voice',
|
|
||||||
},
|
|
||||||
speech_recognizer_vendor: {
|
|
||||||
isString: true,
|
|
||||||
optional: true,
|
|
||||||
errorMessage: 'Invalid speech_recognizer_vendor',
|
|
||||||
},
|
|
||||||
speech_recognizer_language: {
|
|
||||||
isString: true,
|
|
||||||
optional: true,
|
|
||||||
errorMessage: 'Invalid speech_recognizer_language',
|
|
||||||
}
|
|
||||||
}, ['body']);
|
|
||||||
|
|
||||||
const customSanitizeFunction = (value) => {
|
|
||||||
try {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
value = value.map((item) => customSanitizeFunction(item));
|
|
||||||
} else if (typeof value === 'object') {
|
|
||||||
Object.keys(value).forEach((key) => {
|
|
||||||
value[key] = customSanitizeFunction(value[key]);
|
|
||||||
});
|
|
||||||
} else if (typeof value === 'string') {
|
|
||||||
/* trims characters at the beginning and at the end of a string */
|
|
||||||
value = value.trim();
|
|
||||||
|
|
||||||
/* Verify strings including 'http' via new URL */
|
|
||||||
if (value.includes('http')) {
|
|
||||||
value = new URL(value).toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
value = `Error: ${error.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
createCallSchema,
|
|
||||||
customSanitizeFunction
|
|
||||||
};
|
|
||||||
@@ -1,211 +1,36 @@
|
|||||||
const uuidv4 = require('uuid-random');
|
const uuidv4 = require('uuid/v4');
|
||||||
const {CallDirection, AllowedSipRecVerbs} = require('./utils/constants');
|
const {CallDirection} = require('./utils/constants');
|
||||||
const {parseSiprecPayload} = require('./utils/siprec-utils');
|
|
||||||
const CallInfo = require('./session/call-info');
|
const CallInfo = require('./session/call-info');
|
||||||
const HttpRequestor = require('./utils/http-requestor');
|
const Requestor = require('./utils/requestor');
|
||||||
const WsRequestor = require('./utils/ws-requestor');
|
|
||||||
const makeTask = require('./tasks/make_task');
|
const makeTask = require('./tasks/make_task');
|
||||||
const parseUri = require('drachtio-srf').parseUri;
|
const parseUri = require('drachtio-srf').parseUri;
|
||||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
const normalizeJambones = require('./utils/normalize-jambones');
|
||||||
const dbUtils = require('./utils/db-utils');
|
|
||||||
const RootSpan = require('./utils/call-tracer');
|
|
||||||
const listTaskNames = require('./utils/summarize-tasks');
|
|
||||||
const {
|
|
||||||
JAMBONES_MYSQL_REFRESH_TTL,
|
|
||||||
JAMBONES_DISABLE_DIRECT_P2P_CALL
|
|
||||||
} = require('./config');
|
|
||||||
const { createJambonzApp } = require('./dynamic-apps');
|
|
||||||
|
|
||||||
module.exports = function(srf, logger) {
|
module.exports = function(srf, logger) {
|
||||||
const {
|
const {lookupAppByPhoneNumber, lookupAppBySid, lookupAppByRealm, lookupAppByTeamsTenant} = srf.locals.dbHelpers;
|
||||||
lookupAppByPhoneNumber,
|
|
||||||
lookupAppByRegex,
|
|
||||||
lookupAppBySid,
|
|
||||||
lookupAppByRealm,
|
|
||||||
lookupAppByTeamsTenant,
|
|
||||||
registrar,
|
|
||||||
lookupClientByAccountAndUsername
|
|
||||||
} = srf.locals.dbHelpers;
|
|
||||||
const {
|
|
||||||
writeAlerts,
|
|
||||||
AlertType
|
|
||||||
} = srf.locals;
|
|
||||||
const {lookupAccountDetails, lookupGoogleCustomVoice} = dbUtils(logger, srf);
|
|
||||||
|
|
||||||
async function initLocals(req, res, next) {
|
function initLocals(req, res, next) {
|
||||||
const callId = req.get('Call-ID');
|
|
||||||
const uri = parseUri(req.uri);
|
|
||||||
logger.info({
|
|
||||||
uri,
|
|
||||||
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 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, callId};
|
|
||||||
|
|
||||||
let clientDb = null;
|
|
||||||
if (req.has('X-Authenticated-User')) {
|
|
||||||
req.locals.originatingUser = req.get('X-Authenticated-User');
|
|
||||||
let clientSettings;
|
|
||||||
const arr = /^(.*)@(.*)/.exec(req.locals.originatingUser);
|
|
||||||
if (arr) {
|
|
||||||
[clientSettings] = await lookupClientByAccountAndUsername(account_sid, arr[1]);
|
|
||||||
}
|
|
||||||
clientDb = await registrar.query(req.locals.originatingUser);
|
|
||||||
clientDb = {
|
|
||||||
...clientDb,
|
|
||||||
...clientSettings,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// check for call to application
|
|
||||||
if (uri.user?.startsWith('app-') && req.locals.originatingUser && clientDb?.allow_direct_app_calling) {
|
|
||||||
const application_sid = uri.user.match(/app-(.*)/)[1];
|
|
||||||
logger.debug(`got application from Request URI header: ${application_sid}`);
|
|
||||||
req.locals.application_sid = application_sid;
|
|
||||||
} else 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}`);
|
|
||||||
req.locals.application_sid = application_sid;
|
|
||||||
}
|
|
||||||
// check for call to queue
|
|
||||||
if (uri.user?.startsWith('queue-') && req.locals.originatingUser && clientDb?.allow_direct_queue_calling) {
|
|
||||||
const queue_name = uri.user.match(/queue-(.*)/)[1];
|
|
||||||
logger.debug(`got Queue from Request URI header: ${queue_name}`);
|
|
||||||
req.locals.queue_name = queue_name;
|
|
||||||
}
|
|
||||||
// check for call to registered user
|
|
||||||
if (!JAMBONES_DISABLE_DIRECT_P2P_CALL && req.locals.originatingUser && clientDb?.allow_direct_user_calling) {
|
|
||||||
const arr = /^(.*)@(.*)/.exec(req.locals.originatingUser);
|
|
||||||
if (arr) {
|
|
||||||
const sipRealm = arr[2];
|
|
||||||
const called_user = `${req.calledNumber}@${sipRealm}`;
|
|
||||||
const reg = await registrar.query(called_user);
|
|
||||||
if (reg) {
|
|
||||||
logger.debug(`got called Number is a registered user: ${called_user}`);
|
|
||||||
req.locals.called_user = called_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 {callId, callSid, account_sid} = req.locals;
|
|
||||||
const rootSpan = new RootSpan('incoming-call', req);
|
|
||||||
const traceId = rootSpan.traceId;
|
|
||||||
|
|
||||||
req.locals = {
|
req.locals = {
|
||||||
...req.locals,
|
callSid,
|
||||||
traceId,
|
logger: logger.child({callId: req.get('Call-ID'), callSid})
|
||||||
logger: logger.child({
|
|
||||||
callId,
|
|
||||||
callSid,
|
|
||||||
accountSid: account_sid,
|
|
||||||
callingNumber: req.callingNumber,
|
|
||||||
calledNumber: req.calledNumber,
|
|
||||||
traceId}),
|
|
||||||
rootSpan
|
|
||||||
};
|
};
|
||||||
|
if (req.has('X-Application-Sid')) {
|
||||||
/**
|
const application_sid = req.get('X-Application-Sid');
|
||||||
* end the span on final failure or cancel from caller;
|
req.locals.logger.debug(`got application from X-Application-Sid header: ${application_sid}`);
|
||||||
* otherwise it will be closed when sip dialog is destroyed
|
req.locals.application_sid = application_sid;
|
||||||
*/
|
}
|
||||||
req.once('cancel', () => {
|
if (req.has('X-Authenticated-User')) req.locals.originatingUser = req.get('X-Authenticated-User');
|
||||||
rootSpan.setAttributes({finalStatus: 487});
|
if (req.has('X-MS-Teams-Tenant-FQDN')) req.locals.msTeamsTenant = req.get('X-MS-Teams-Tenant-FQDN');
|
||||||
rootSpan.end();
|
|
||||||
});
|
|
||||||
res.once('finish', () => {
|
|
||||||
rootSpan.setAttributes({finalStatus: res.statusCode});
|
|
||||||
res.statusCode >= 300 && rootSpan.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
next();
|
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({err, callId}, 'Error parsing multipart payload');
|
|
||||||
return res.send(503);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* retrieve account information for the incoming call
|
|
||||||
*/
|
|
||||||
async function getAccountDetails(req, res, next) {
|
|
||||||
const {rootSpan, account_sid} = req.locals;
|
|
||||||
|
|
||||||
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}`);
|
|
||||||
// TODO: alert
|
|
||||||
return res.send(503, {headers: {'X-Reason': 'Account exists but is inactive'}});
|
|
||||||
}
|
|
||||||
logger.debug({accountInfo: req.locals?.accountInfo?.account}, `retrieved account info for ${account_sid}`);
|
|
||||||
next();
|
|
||||||
} catch (err) {
|
|
||||||
span.end();
|
|
||||||
logger.info({err}, `Error retrieving account details for account ${account_sid}`);
|
|
||||||
res.send(503, {headers: {'X-Reason': `No Account exists for sid ${account_sid}`}});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Within the system, we deal with E.164 numbers _without_ the leading '+
|
* Within the system, we deal with E.164 numbers _without_ the leading '+
|
||||||
*/
|
*/
|
||||||
function normalizeNumbers(req, res, next) {
|
function normalizeNumbers(req, res, next) {
|
||||||
const {logger, siprec} = req.locals;
|
const logger = req.locals.logger;
|
||||||
|
|
||||||
if (siprec) return next();
|
|
||||||
|
|
||||||
Object.assign(req.locals, {
|
Object.assign(req.locals, {
|
||||||
calledNumber: req.calledNumber,
|
calledNumber: req.calledNumber,
|
||||||
callingNumber: req.callingNumber
|
callingNumber: req.callingNumber
|
||||||
@@ -226,28 +51,18 @@ module.exports = function(srf, logger) {
|
|||||||
* Given the dialed DID/phone number, retrieve the application to invoke
|
* Given the dialed DID/phone number, retrieve the application to invoke
|
||||||
*/
|
*/
|
||||||
async function retrieveApplication(req, res, next) {
|
async function retrieveApplication(req, res, next) {
|
||||||
const {logger, accountInfo, account_sid, rootSpan} = req.locals;
|
const logger = req.locals.logger;
|
||||||
const {span} = rootSpan.startChildSpan('lookupApplication');
|
|
||||||
try {
|
try {
|
||||||
let app;
|
let app;
|
||||||
if (req.locals.queue_name) {
|
if (req.locals.application_sid) app = await lookupAppBySid(req.locals.application_sid);
|
||||||
logger.debug(`calling to queue ${req.locals.queue_name}, generating queue app`);
|
else if (req.locals.originatingUser) {
|
||||||
app = createJambonzApp('queue', {account_sid, name: req.locals.queue_name});
|
|
||||||
} else if (req.locals.called_user) {
|
|
||||||
logger.debug(`calling to registered user ${req.locals.called_user}, generating dial app`);
|
|
||||||
app = createJambonzApp('user',
|
|
||||||
{account_sid, name: req.locals.called_user, caller_id: req.locals.callingNumber});
|
|
||||||
} else if (req.locals.application_sid) {
|
|
||||||
app = await lookupAppBySid(req.locals.application_sid);
|
|
||||||
} else if (req.locals.originatingUser) {
|
|
||||||
const arr = /^(.*)@(.*)/.exec(req.locals.originatingUser);
|
const arr = /^(.*)@(.*)/.exec(req.locals.originatingUser);
|
||||||
if (arr) {
|
if (arr) {
|
||||||
const sipRealm = arr[2];
|
const sipRealm = arr[2];
|
||||||
logger.debug(`looking for device calling app for realm ${sipRealm}`);
|
logger.debug(`looking for device calling app for realm ${sipRealm}`);
|
||||||
app = await lookupAppByRealm(sipRealm);
|
app = await lookupAppByRealm(sipRealm);
|
||||||
if (app) {
|
if (app) logger.debug({app}, `retrieved device calling app for realm ${sipRealm}`);
|
||||||
logger.debug({app}, `retrieved device calling app for realm ${sipRealm}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (req.locals.msTeamsTenant) {
|
else if (req.locals.msTeamsTenant) {
|
||||||
@@ -256,7 +71,7 @@ module.exports = function(srf, logger) {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const uri = parseUri(req.uri);
|
const uri = parseUri(req.uri);
|
||||||
const arr = /context-(.*)/.exec(uri?.user);
|
const arr = /context-(.*)/.exec(uri.user);
|
||||||
if (arr) {
|
if (arr) {
|
||||||
// this is a transfer from another feature server
|
// this is a transfer from another feature server
|
||||||
const {retrieveKey, deleteKey} = srf.locals.dbHelpers;
|
const {retrieveKey, deleteKey} = srf.locals.dbHelpers;
|
||||||
@@ -269,22 +84,9 @@ module.exports = function(srf, logger) {
|
|||||||
logger.error(err, `Error retrieving transferred call app for ${arr[1]}`);
|
logger.error(err, `Error retrieving transferred call app for ${arr[1]}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else app = await lookupAppByPhoneNumber(req.locals.calledNumber);
|
||||||
const voip_carrier_sid = req.get('X-Voip-Carrier-Sid');
|
|
||||||
app = await lookupAppByPhoneNumber(req.locals.calledNumber, voip_carrier_sid);
|
|
||||||
|
|
||||||
if (!app) {
|
|
||||||
/* lookup by call_routes.regex */
|
|
||||||
app = await lookupAppByRegex(req.locals.calledNumber, account_sid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
span.setAttributes({
|
|
||||||
'app.hook': app?.call_hook?.url,
|
|
||||||
'application_sid': req.locals.application_sid
|
|
||||||
});
|
|
||||||
span.end();
|
|
||||||
if (!app || !app.call_hook || !app.call_hook.url) {
|
if (!app || !app.call_hook || !app.call_hook.url) {
|
||||||
logger.info(`rejecting call to ${req.locals.calledNumber}: no application or webhook url`);
|
logger.info(`rejecting call to ${req.locals.calledNumber}: no application or webhook url`);
|
||||||
return res.send(480, {
|
return res.send(480, {
|
||||||
@@ -298,54 +100,18 @@ module.exports = function(srf, logger) {
|
|||||||
* create a requestor that we will use for all http requests we make during the call.
|
* create a requestor that we will use for all http requests we make during the call.
|
||||||
* also create a notifier for call status events (if not needed, its a no-op).
|
* also create a notifier for call status events (if not needed, its a no-op).
|
||||||
*/
|
*/
|
||||||
|
app.requestor = new Requestor(logger, app.call_hook);
|
||||||
|
if (app.call_status_hook) app.notifier = new Requestor(logger, app.call_status_hook);
|
||||||
|
else app.notifier = {request: () => {}};
|
||||||
|
|
||||||
/* allow for caching data - when caching treat retrieved data as immutable */
|
req.locals.application = app;
|
||||||
const app2 = JAMBONES_MYSQL_REFRESH_TTL ? JSON.parse(JSON.stringify(app)) : app;
|
const obj = Object.assign({}, app);
|
||||||
if ('WS' === app.call_hook?.method ||
|
delete obj.requestor;
|
||||||
app.call_hook?.url.startsWith('ws://') || app.call_hook?.url.startsWith('wss://')) {
|
delete obj.notifier;
|
||||||
const requestor = new WsRequestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret) ;
|
logger.info({app: obj}, `retrieved application for incoming call to ${req.locals.calledNumber}`);
|
||||||
app2.requestor = requestor;
|
req.locals.callInfo = new CallInfo({req, app, direction: CallDirection.Inbound});
|
||||||
app2.notifier = requestor;
|
|
||||||
app2.call_hook.method = 'WS';
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
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 app2.notifier = {request: () => {}, close: () => {}};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve application.speech_synthesis_voice if it's custom voice
|
|
||||||
if (app2.speech_synthesis_vendor === 'google' && app2.speech_synthesis_voice.startsWith('custom_')) {
|
|
||||||
const arr = /custom_(.*)/.exec(app2.speech_synthesis_voice);
|
|
||||||
if (arr) {
|
|
||||||
const google_custom_voice_sid = arr[1];
|
|
||||||
const [custom_voice] = await lookupGoogleCustomVoice(google_custom_voice_sid);
|
|
||||||
if (custom_voice) {
|
|
||||||
app2.speech_synthesis_voice = {
|
|
||||||
reportedUsage: custom_voice.reported_usage,
|
|
||||||
model: custom_voice.model
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
req.locals.application = app2;
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
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: app2,
|
|
||||||
direction: CallDirection.Inbound,
|
|
||||||
traceId: rootSpan.traceId
|
|
||||||
});
|
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
span.end();
|
|
||||||
logger.error(err, `${req.get('Call-ID')} Error looking up application for ${req.calledNumber}`);
|
logger.error(err, `${req.get('Call-ID')} Error looking up application for ${req.calledNumber}`);
|
||||||
res.send(500);
|
res.send(500);
|
||||||
}
|
}
|
||||||
@@ -356,91 +122,29 @@ module.exports = function(srf, logger) {
|
|||||||
*/
|
*/
|
||||||
async function invokeWebCallback(req, res, next) {
|
async function invokeWebCallback(req, res, next) {
|
||||||
const logger = req.locals.logger;
|
const logger = req.locals.logger;
|
||||||
const {rootSpan, siprec, application:app} = req.locals;
|
const app = req.locals.application;
|
||||||
let span;
|
|
||||||
try {
|
try {
|
||||||
if (app.tasks && !JAMBONES_MYSQL_REFRESH_TTL) {
|
|
||||||
|
if (app.tasks) {
|
||||||
app.tasks = normalizeJambones(logger, app.tasks).map((tdata) => makeTask(logger, tdata));
|
app.tasks = normalizeJambones(logger, app.tasks).map((tdata) => makeTask(logger, tdata));
|
||||||
if (0 === app.tasks.length) throw new Error('no application provided');
|
if (0 === app.tasks.length) throw new Error('no application provided');
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
/* retrieve the application to execute for this inbound call */
|
/* retrieve the application to execute for this inbound call */
|
||||||
let json;
|
const params = Object.assign(app.call_hook.method === 'POST' ? {sip: req.msg} : {},
|
||||||
if (app.app_json) {
|
req.locals.callInfo);
|
||||||
json = JSON.parse(app.app_json);
|
const json = await app.requestor.request(app.call_hook, params);
|
||||||
} else {
|
|
||||||
const defaults = {
|
|
||||||
synthesizer: {
|
|
||||||
vendor: app.speech_synthesis_vendor,
|
|
||||||
...(app.speech_synthesis_label && {label: app.speech_synthesis_label}),
|
|
||||||
language: app.speech_synthesis_language,
|
|
||||||
voice: app.speech_synthesis_voice,
|
|
||||||
...(app.fallback_speech_synthesis_vendor && {fallback_vendor: app.fallback_speech_synthesis_vendor}),
|
|
||||||
...(app.fallback_speech_synthesis_label && {fallback_label: app.fallback_speech_synthesis_label}),
|
|
||||||
...(app.fallback_speech_synthesis_language && {fallback_language: app.fallback_speech_synthesis_language}),
|
|
||||||
...(app.fallback_speech_synthesis_voice && {fallback_voice: app.fallback_speech_synthesis_voice})
|
|
||||||
},
|
|
||||||
recognizer: {
|
|
||||||
vendor: app.speech_recognizer_vendor,
|
|
||||||
...(app.speech_synthesis_label && {label: app.speech_synthesis_label}),
|
|
||||||
language: app.speech_recognizer_language,
|
|
||||||
...(app.fallback_speech_recognizer_vendor && {fallback_vendor: app.fallback_speech_recognizer_vendor}),
|
|
||||||
...(app.fallback_speech_recognizer_label && {fallback_label: app.fallback_speech_recognizer_label}),
|
|
||||||
...(app.fallback_speech_recognizer_language && {fallback_language: app.fallback_speech_recognizer_language})
|
|
||||||
}
|
|
||||||
};
|
|
||||||
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 });
|
|
||||||
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));
|
app.tasks = normalizeJambones(logger, json).map((tdata) => makeTask(logger, tdata));
|
||||||
span?.setAttributes({
|
|
||||||
'http.statusCode': 200,
|
|
||||||
'app.tasks': listTaskNames(app.tasks)
|
|
||||||
});
|
|
||||||
span?.end();
|
|
||||||
if (0 === app.tasks.length) throw new Error('no application provided');
|
if (0 === app.tasks.length) throw new Error('no application provided');
|
||||||
|
|
||||||
if (siprec) {
|
|
||||||
const tasks = app.tasks.filter((t) => AllowedSipRecVerbs.includes(t.name));
|
|
||||||
if (0 === tasks.length) {
|
|
||||||
logger.info({tasks: app.tasks}, 'no valid verbs in app found for an incoming siprec call');
|
|
||||||
throw new Error('invalid verbs for incoming siprec call');
|
|
||||||
}
|
|
||||||
if (tasks.length < app.tasks.length) {
|
|
||||||
logger.info('removing verbs that are not allowed for incoming siprec call');
|
|
||||||
app.tasks = tasks;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
span?.setAttributes({webhookStatus: err.statusCode});
|
logger.info(`Error retrieving or parsing application: ${err.message}`);
|
||||||
span?.end();
|
res.send(480, {headers: {'X-Reason': err.message}});
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initLocals,
|
initLocals,
|
||||||
createRootSpan,
|
|
||||||
handleSipRec,
|
|
||||||
getAccountDetails,
|
|
||||||
normalizeNumbers,
|
normalizeNumbers,
|
||||||
retrieveApplication,
|
retrieveApplication,
|
||||||
invokeWebCallback
|
invokeWebCallback
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
const CallSession = require('./call-session');
|
|
||||||
const {CallStatus} = require('../utils/constants');
|
|
||||||
const moment = require('moment');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @classdesc Subclass of CallSession. Represents a CallSession
|
|
||||||
* that was initially a child call leg; i.e. established via a Dial verb.
|
|
||||||
* Now it is all grown up and filling out its own CallSession. Yoo-hoo!
|
|
||||||
* @extends CallSession
|
|
||||||
|
|
||||||
*/
|
|
||||||
class AdultingCallSession extends CallSession {
|
|
||||||
constructor({logger, application, singleDialer, tasks, callInfo, accountInfo, rootSpan}) {
|
|
||||||
super({
|
|
||||||
logger,
|
|
||||||
application,
|
|
||||||
srf: singleDialer.dlg.srf,
|
|
||||||
tasks,
|
|
||||||
callInfo,
|
|
||||||
accountInfo,
|
|
||||||
rootSpan
|
|
||||||
});
|
|
||||||
this.sd = singleDialer;
|
|
||||||
this.req = callInfo.req;
|
|
||||||
|
|
||||||
this.sd.dlg.on('destroy', () => {
|
|
||||||
this.logger.info('AdultingCallSession: called party hung up');
|
|
||||||
this._callReleased();
|
|
||||||
});
|
|
||||||
this.sd.emit('adulting');
|
|
||||||
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
get dlg() {
|
|
||||||
return this.sd.dlg;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Note: this is not an error. It is only here to avoid an assert ("no setter for dlg")
|
|
||||||
* when there is a call in Session:_clearResources to null out dlg and ep
|
|
||||||
*/
|
|
||||||
set dlg(newDlg) {}
|
|
||||||
|
|
||||||
get ep() {
|
|
||||||
return this.sd.ep;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* see note above */
|
|
||||||
set ep(newEp) {}
|
|
||||||
|
|
||||||
get callSid() {
|
|
||||||
return this.callInfo.callSid;
|
|
||||||
}
|
|
||||||
|
|
||||||
_callerHungup() {
|
|
||||||
if (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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.logger.info('InboundCallSession: caller hung up');
|
|
||||||
this._callReleased();
|
|
||||||
this.req.removeAllListeners('cancel');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = AdultingCallSession;
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
const {CallDirection, CallStatus} = require('../utils/constants');
|
const {CallDirection, CallStatus} = require('../utils/constants');
|
||||||
const parseUri = require('drachtio-srf').parseUri;
|
const parseUri = require('drachtio-srf').parseUri;
|
||||||
const uuidv4 = require('uuid-random');
|
const uuidv4 = require('uuid/v4');
|
||||||
const {JAMBONES_API_BASE_URL} = require('../config');
|
|
||||||
/**
|
/**
|
||||||
* @classdesc Represents the common information for all calls
|
* @classdesc Represents the common information for all calls
|
||||||
* that is provided in call status webhooks
|
* that is provided in call status webhooks
|
||||||
@@ -9,10 +9,7 @@ const {JAMBONES_API_BASE_URL} = require('../config');
|
|||||||
class CallInfo {
|
class CallInfo {
|
||||||
constructor(opts) {
|
constructor(opts) {
|
||||||
let from ;
|
let from ;
|
||||||
let srf;
|
|
||||||
this.direction = opts.direction;
|
this.direction = opts.direction;
|
||||||
this.traceId = opts.traceId;
|
|
||||||
this.callTerminationBy = undefined;
|
|
||||||
if (opts.req) {
|
if (opts.req) {
|
||||||
const u = opts.req.getParsedHeader('from');
|
const u = opts.req.getParsedHeader('from');
|
||||||
const uri = parseUri(u.uri);
|
const uri = parseUri(u.uri);
|
||||||
@@ -22,7 +19,6 @@ class CallInfo {
|
|||||||
if (this.direction === CallDirection.Inbound) {
|
if (this.direction === CallDirection.Inbound) {
|
||||||
// inbound call
|
// inbound call
|
||||||
const {app, req} = opts;
|
const {app, req} = opts;
|
||||||
srf = req.srf;
|
|
||||||
this.callSid = req.locals.callSid,
|
this.callSid = req.locals.callSid,
|
||||||
this.accountSid = app.account_sid,
|
this.accountSid = app.account_sid,
|
||||||
this.applicationSid = app.application_sid;
|
this.applicationSid = app.application_sid;
|
||||||
@@ -30,32 +26,13 @@ class CallInfo {
|
|||||||
this.to = req.calledNumber;
|
this.to = req.calledNumber;
|
||||||
this.callId = req.get('Call-ID');
|
this.callId = req.get('Call-ID');
|
||||||
this.sipStatus = 100;
|
this.sipStatus = 100;
|
||||||
this.sipReason = 'Trying';
|
|
||||||
this.callStatus = CallStatus.Trying;
|
this.callStatus = CallStatus.Trying;
|
||||||
this.originatingSipIp = req.get('X-Forwarded-For');
|
this.originatingSipIp = req.get('X-Forwarded-For');
|
||||||
this.originatingSipTrunkName = req.get('X-Originating-Carrier');
|
this.originatingSipTrunkName = req.get('X-Originating-Carrier');
|
||||||
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) {
|
else if (opts.parentCallInfo) {
|
||||||
// outbound call that is a child of an existing call
|
// outbound call that is a child of an existing call
|
||||||
const {req, parentCallInfo, to, callSid} = opts;
|
const {req, parentCallInfo, to, callSid} = opts;
|
||||||
srf = req.srf;
|
|
||||||
this.callSid = callSid || uuidv4();
|
this.callSid = callSid || uuidv4();
|
||||||
this.parentCallSid = parentCallInfo.callSid;
|
this.parentCallSid = parentCallInfo.callSid;
|
||||||
this.accountSid = parentCallInfo.accountSid;
|
this.accountSid = parentCallInfo.accountSid;
|
||||||
@@ -66,37 +43,20 @@ class CallInfo {
|
|||||||
this.callId = req.get('Call-ID');
|
this.callId = req.get('Call-ID');
|
||||||
this.callStatus = CallStatus.Trying,
|
this.callStatus = CallStatus.Trying,
|
||||||
this.sipStatus = 100;
|
this.sipStatus = 100;
|
||||||
this.sipReason = 'Trying';
|
|
||||||
}
|
|
||||||
else if (this.direction === CallDirection.None) {
|
|
||||||
// outbound SMS
|
|
||||||
const {messageSid, accountSid, applicationSid, res} = opts;
|
|
||||||
srf = res.srf;
|
|
||||||
this.messageSid = messageSid;
|
|
||||||
this.accountSid = accountSid;
|
|
||||||
this.applicationSid = applicationSid;
|
|
||||||
this.res = res;
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// outbound call triggered by REST
|
// outbound call triggered by REST
|
||||||
const {req, callSid, accountSid, applicationSid, to, tag} = opts;
|
const {req, accountSid, applicationSid, to, tag} = opts;
|
||||||
srf = req.srf;
|
this.callSid = uuidv4();
|
||||||
this.callSid = callSid;
|
|
||||||
this.accountSid = accountSid;
|
this.accountSid = accountSid;
|
||||||
this.applicationSid = applicationSid;
|
this.applicationSid = applicationSid;
|
||||||
this.callStatus = CallStatus.Trying,
|
this.callStatus = CallStatus.Trying,
|
||||||
this.callId = req.get('Call-ID');
|
this.callId = req.get('Call-ID');
|
||||||
this.sipStatus = 100;
|
this.sipStatus = 100;
|
||||||
this.sipReason = 'Trying';
|
|
||||||
this.from = from || req.callingNumber;
|
this.from = from || req.callingNumber;
|
||||||
this.to = to;
|
this.to = to;
|
||||||
if (tag) this._customerData = tag;
|
if (tag) this._customerData = tag;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.localSipAddress = srf.locals.localSipAddress;
|
|
||||||
if (srf.locals.publicIp) {
|
|
||||||
this.publicIp = srf.locals.publicIp;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,10 +64,9 @@ class CallInfo {
|
|||||||
* @param {string} callStatus - current call status
|
* @param {string} callStatus - current call status
|
||||||
* @param {number} sipStatus - current sip status
|
* @param {number} sipStatus - current sip status
|
||||||
*/
|
*/
|
||||||
updateCallStatus(callStatus, sipStatus, sipReason) {
|
updateCallStatus(callStatus, sipStatus) {
|
||||||
this.callStatus = callStatus;
|
this.callStatus = callStatus;
|
||||||
if (sipStatus) this.sipStatus = sipStatus;
|
if (sipStatus) this.sipStatus = sipStatus;
|
||||||
if (sipReason) this.sipReason = sipReason;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,15 +89,12 @@ class CallInfo {
|
|||||||
to: this.to,
|
to: this.to,
|
||||||
callId: this.callId,
|
callId: this.callId,
|
||||||
sipStatus: this.sipStatus,
|
sipStatus: this.sipStatus,
|
||||||
sipReason: this.sipReason,
|
|
||||||
callStatus: this.callStatus,
|
callStatus: this.callStatus,
|
||||||
callerId: this.callerId,
|
callerId: this.callerId,
|
||||||
accountSid: this.accountSid,
|
accountSid: this.accountSid,
|
||||||
traceId: this.traceId,
|
applicationSid: this.applicationSid
|
||||||
applicationSid: this.applicationSid,
|
|
||||||
fsSipAddress: this.localSipAddress
|
|
||||||
};
|
};
|
||||||
['parentCallSid', 'originatingSipIp', 'originatingSipTrunkName', 'callTerminationBy'].forEach((prop) => {
|
['parentCallSid', 'originatingSipIp', 'originatingSipTrunkName'].forEach((prop) => {
|
||||||
if (this[prop]) obj[prop] = this[prop];
|
if (this[prop]) obj[prop] = this[prop];
|
||||||
});
|
});
|
||||||
if (typeof this.duration === 'number') obj.duration = this.duration;
|
if (typeof this.duration === 'number') obj.duration = this.duration;
|
||||||
@@ -146,13 +102,6 @@ class CallInfo {
|
|||||||
if (this._customerData) {
|
if (this._customerData) {
|
||||||
Object.assign(obj, {customerData: this._customerData});
|
Object.assign(obj, {customerData: this._customerData});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (JAMBONES_API_BASE_URL) {
|
|
||||||
Object.assign(obj, {apiBaseUrl: JAMBONES_API_BASE_URL});
|
|
||||||
}
|
|
||||||
if (this.publicIp) {
|
|
||||||
Object.assign(obj, {fsPublicIp: this.publicIp});
|
|
||||||
}
|
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,18 +8,14 @@ const CallSession = require('./call-session');
|
|||||||
|
|
||||||
*/
|
*/
|
||||||
class ConfirmCallSession extends CallSession {
|
class ConfirmCallSession extends CallSession {
|
||||||
constructor({logger, application, dlg, ep, tasks, callInfo, accountInfo, memberId, confName, rootSpan}) {
|
constructor({logger, application, dlg, ep, tasks, callInfo}) {
|
||||||
super({
|
super({
|
||||||
logger,
|
logger,
|
||||||
application,
|
application,
|
||||||
srf: dlg.srf,
|
srf: dlg.srf,
|
||||||
callSid: dlg.callSid,
|
callSid: dlg.callSid,
|
||||||
tasks,
|
tasks,
|
||||||
callInfo,
|
callInfo
|
||||||
accountInfo,
|
|
||||||
memberId,
|
|
||||||
confName,
|
|
||||||
rootSpan
|
|
||||||
});
|
});
|
||||||
this.dlg = dlg;
|
this.dlg = dlg;
|
||||||
this.ep = ep;
|
this.ep = ep;
|
||||||
@@ -31,10 +27,6 @@ class ConfirmCallSession extends CallSession {
|
|||||||
_clearResources() {
|
_clearResources() {
|
||||||
}
|
}
|
||||||
|
|
||||||
_callerHungup() {
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = ConfirmCallSession;
|
module.exports = ConfirmCallSession;
|
||||||
|
|||||||
@@ -15,38 +15,23 @@ class InboundCallSession extends CallSession {
|
|||||||
srf: req.srf,
|
srf: req.srf,
|
||||||
application: req.locals.application,
|
application: req.locals.application,
|
||||||
callInfo: req.locals.callInfo,
|
callInfo: req.locals.callInfo,
|
||||||
accountInfo: req.locals.accountInfo,
|
tasks: req.locals.application.tasks
|
||||||
tasks: req.locals.application.tasks,
|
|
||||||
rootSpan: req.locals.rootSpan
|
|
||||||
});
|
});
|
||||||
this.req = req;
|
this.req = req;
|
||||||
this.res = res;
|
this.res = res;
|
||||||
|
|
||||||
req.once('cancel', this._onCancel.bind(this));
|
req.on('cancel', () => {
|
||||||
|
this._notifyCallStatusChange({callStatus: CallStatus.NoAnswer, sipStatus: 487});
|
||||||
|
this._callReleased();
|
||||||
|
});
|
||||||
|
|
||||||
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
|
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
|
||||||
this._notifyCallStatusChange({
|
this._notifyCallStatusChange({callStatus: CallStatus.Trying, sipStatus: 100});
|
||||||
callStatus: CallStatus.Trying,
|
|
||||||
sipStatus: 100,
|
|
||||||
sipReason: 'Trying'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_onCancel() {
|
|
||||||
this.rootSpan.setAttributes({'call.termination': 'caller abandoned'});
|
|
||||||
this.callInfo.callTerminationBy = 'caller';
|
|
||||||
this._notifyCallStatusChange({
|
|
||||||
callStatus: CallStatus.NoAnswer,
|
|
||||||
sipStatus: 487,
|
|
||||||
sipReason: 'Request Terminated'
|
|
||||||
});
|
|
||||||
this._callReleased();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_onTasksDone() {
|
_onTasksDone() {
|
||||||
if (!this.res.finalResponseSent) {
|
if (!this.res.finalResponseSent) {
|
||||||
if (this._mediaServerFailure) {
|
if (this._mediaServerFailure) {
|
||||||
this.rootSpan.setAttributes({'call.termination': 'media server failure'});
|
|
||||||
this.logger.info('InboundCallSession:_onTasksDone generating 480 due to media server failure');
|
this.logger.info('InboundCallSession:_onTasksDone generating 480 due to media server failure');
|
||||||
this.res.send(480, {
|
this.res.send(480, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -55,12 +40,10 @@ class InboundCallSession extends CallSession {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
this.rootSpan.setAttributes({'call.termination': 'tasks completed without answering call'});
|
|
||||||
this.logger.info('InboundCallSession:_onTasksDone auto-generating non-success response to invite');
|
this.logger.info('InboundCallSession:_onTasksDone auto-generating non-success response to invite');
|
||||||
this.res.send(603);
|
this.res.send(603);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.req.removeAllListeners('cancel');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,15 +52,9 @@ class InboundCallSession extends CallSession {
|
|||||||
_callerHungup() {
|
_callerHungup() {
|
||||||
assert(this.dlg.connectTime);
|
assert(this.dlg.connectTime);
|
||||||
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
||||||
this.rootSpan.setAttributes({'call.termination': 'hangup by caller'});
|
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||||
this.callInfo.callTerminationBy = 'caller';
|
this.logger.debug('InboundCallSession: caller hung up');
|
||||||
this.emit('callStatusChange', {
|
|
||||||
callStatus: CallStatus.Completed,
|
|
||||||
duration
|
|
||||||
});
|
|
||||||
this.logger.info('InboundCallSession: caller hung up');
|
|
||||||
this._callReleased();
|
this._callReleased();
|
||||||
this.req.removeAllListeners('cancel');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,31 +8,20 @@ const moment = require('moment');
|
|||||||
* @extends CallSession
|
* @extends CallSession
|
||||||
*/
|
*/
|
||||||
class RestCallSession extends CallSession {
|
class RestCallSession extends CallSession {
|
||||||
constructor({logger, application, srf, req, ep, ep2, tasks, callInfo, accountInfo, rootSpan}) {
|
constructor({logger, application, srf, req, ep, tasks, callInfo}) {
|
||||||
super({
|
super({
|
||||||
logger,
|
logger,
|
||||||
application,
|
application,
|
||||||
srf,
|
srf,
|
||||||
callSid: callInfo.callSid,
|
callSid: callInfo.callSid,
|
||||||
tasks,
|
tasks,
|
||||||
callInfo,
|
callInfo
|
||||||
accountInfo,
|
|
||||||
rootSpan
|
|
||||||
});
|
});
|
||||||
this.req = req;
|
this.req = req;
|
||||||
this.ep = ep;
|
this.ep = ep;
|
||||||
this.ep2 = ep2;
|
|
||||||
// keep restDialTask reference for closing AMD
|
|
||||||
if (tasks.length) {
|
|
||||||
this.restDialTask = tasks[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
|
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
|
||||||
this._notifyCallStatusChange({
|
this._notifyCallStatusChange({callStatus: CallStatus.Trying, sipStatus: 100});
|
||||||
callStatus: CallStatus.Trying,
|
|
||||||
sipStatus: 100,
|
|
||||||
sipReason: 'Trying'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,10 +38,6 @@ class RestCallSession extends CallSession {
|
|||||||
* This is invoked when the called party hangs up, in order to calculate the call duration.
|
* This is invoked when the called party hangs up, in order to calculate the call duration.
|
||||||
*/
|
*/
|
||||||
_callerHungup() {
|
_callerHungup() {
|
||||||
if (this.restDialTask) {
|
|
||||||
this.restDialTask.turnOffAmd();
|
|
||||||
}
|
|
||||||
this.callInfo.callTerminationBy = 'caller';
|
|
||||||
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
||||||
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||||
this.logger.debug('RestCallSession: called party hung up');
|
this.logger.debug('RestCallSession: called party hung up');
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
const CallSession = require('./call-session');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @classdesc Subclass of CallSession. Represents a CallSession
|
|
||||||
* that is established for the purpose of sending an outbound SMS
|
|
||||||
* @extends CallSession
|
|
||||||
|
|
||||||
*/
|
|
||||||
class SmsCallSession extends CallSession {
|
|
||||||
constructor({logger, application, srf, tasks, callInfo}) {
|
|
||||||
super({
|
|
||||||
logger,
|
|
||||||
application,
|
|
||||||
srf,
|
|
||||||
tasks,
|
|
||||||
callInfo
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = SmsCallSession;
|
|
||||||
@@ -2,7 +2,7 @@ const Task = require('./task');
|
|||||||
const Emitter = require('events');
|
const Emitter = require('events');
|
||||||
const ConfirmCallSession = require('../session/confirm-call-session');
|
const ConfirmCallSession = require('../session/confirm-call-session');
|
||||||
const {TaskName, TaskPreconditions, BONG_TONE} = require('../utils/constants');
|
const {TaskName, TaskPreconditions, BONG_TONE} = require('../utils/constants');
|
||||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
const normalizeJambones = require('../utils/normalize-jambones');
|
||||||
const makeTask = require('./make_task');
|
const makeTask = require('./make_task');
|
||||||
const bent = require('bent');
|
const bent = require('bent');
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
@@ -10,7 +10,6 @@ const WAIT = 'wait';
|
|||||||
const JOIN = 'join';
|
const JOIN = 'join';
|
||||||
const START = 'start';
|
const START = 'start';
|
||||||
|
|
||||||
|
|
||||||
function confNoMatch(str) {
|
function confNoMatch(str) {
|
||||||
return str.match(/^No active conferences/) || str.match(/Conference.*not found/);
|
return str.match(/^No active conferences/) || str.match(/Conference.*not found/);
|
||||||
}
|
}
|
||||||
@@ -28,8 +27,7 @@ function camelize(str) {
|
|||||||
|
|
||||||
function unhandled(logger, cs, evt) {
|
function unhandled(logger, cs, evt) {
|
||||||
this.participantCount = parseInt(evt.getHeader('Conference-Size'));
|
this.participantCount = parseInt(evt.getHeader('Conference-Size'));
|
||||||
// logger.debug({evt}, `unhandled conference event: ${evt.getHeader('Action')}`) ;
|
logger.debug({evt}, `unhandled conference event: ${evt.getHeader('Action')}`) ;
|
||||||
logger.debug(`unhandled conference event: ${evt.getHeader('Action')}`) ;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function capitalize(s) {
|
function capitalize(s) {
|
||||||
@@ -47,10 +45,10 @@ class Conference extends Task {
|
|||||||
|
|
||||||
this.confName = this.data.name;
|
this.confName = this.data.name;
|
||||||
[
|
[
|
||||||
'beep', 'startConferenceOnEnter', 'endConferenceOnExit', 'joinMuted',
|
'beep', 'startConferenceOnEnter', 'endConferenceOnExit',
|
||||||
'maxParticipants', 'waitHook', 'statusHook', 'endHook', 'enterHook', 'endConferenceDuration'
|
'maxParticipants', 'waitHook', 'statusHook', 'endHook', 'enterHook'
|
||||||
].forEach((attr) => this[attr] = this.data[attr]);
|
].forEach((attr) => this[attr] = this.data[attr]);
|
||||||
this.record = this.data.record || {};
|
|
||||||
this.statusEvents = [];
|
this.statusEvents = [];
|
||||||
if (this.statusHook) {
|
if (this.statusHook) {
|
||||||
['start', 'end', 'join', 'leave', 'start-talking', 'stop-talking'].forEach((e) => {
|
['start', 'end', 'join', 'leave', 'start-talking', 'stop-talking'].forEach((e) => {
|
||||||
@@ -69,10 +67,7 @@ class Conference extends Task {
|
|||||||
|
|
||||||
get name() { return TaskName.Conference; }
|
get name() { return TaskName.Conference; }
|
||||||
|
|
||||||
get shouldRecord() { return this.record.path; }
|
async exec(cs, ep) {
|
||||||
get isRecording() { return this.recordingInProgress; }
|
|
||||||
|
|
||||||
async exec(cs, {ep}) {
|
|
||||||
await super.exec(cs);
|
await super.exec(cs);
|
||||||
this.ep = ep;
|
this.ep = ep;
|
||||||
const dlg = cs.dlg;
|
const dlg = cs.dlg;
|
||||||
@@ -108,18 +103,9 @@ class Conference extends Task {
|
|||||||
async kill(cs) {
|
async kill(cs) {
|
||||||
super.kill(cs);
|
super.kill(cs);
|
||||||
this.logger.info(`Conference:kill ${this.confName}`);
|
this.logger.info(`Conference:kill ${this.confName}`);
|
||||||
if (this._playSession) {
|
|
||||||
this._playSession.kill();
|
|
||||||
this._playSession = null;
|
|
||||||
}
|
|
||||||
this.emitter.emit('kill');
|
this.emitter.emit('kill');
|
||||||
await this._doFinalMemberCheck(cs);
|
await this._doFinalMemberCheck(cs);
|
||||||
if (this.ep && this.ep.connected) {
|
if (this.ep && this.ep.connected) this.ep.conn.removeAllListeners('esl::event::CUSTOM::*') ;
|
||||||
this.ep.conn.removeAllListeners('esl::event::CUSTOM::*');
|
|
||||||
this.ep.api(`conference ${this.confName} kick ${this.memberId}`)
|
|
||||||
.catch((err) => this.logger.info({err}, 'Error kicking participant'));
|
|
||||||
}
|
|
||||||
cs.clearConferenceDetails();
|
|
||||||
this.notifyTaskDone();
|
this.notifyTaskDone();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +213,6 @@ class Conference extends Task {
|
|||||||
this._playSession.kill();
|
this._playSession.kill();
|
||||||
this._playSession = null;
|
this._playSession = null;
|
||||||
}
|
}
|
||||||
cs.clearConferenceDetails();
|
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -344,35 +329,16 @@ class Conference extends Task {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const opts = {};
|
const opts = {};
|
||||||
if (this.endConferenceOnExit || this.startConferenceOnEnter || this.joinMuted) {
|
if (this.endConferenceOnExit) Object.assign(opts, {flags: {endconf: true}});
|
||||||
Object.assign(opts, {flags: {
|
|
||||||
...(this.endConferenceOnExit && {endconf: true}),
|
|
||||||
...(this.startConferenceOnEnter && {moderator: true}),
|
|
||||||
...(this.joinMuted && {joinMuted: true}),
|
|
||||||
}});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {memberId, confUuid} = await this.ep.join(this.confName, opts);
|
const {memberId, confUuid} = await this.ep.join(this.confName, opts);
|
||||||
this.logger.debug({memberId, confUuid}, `Conference:_joinConference: successfully joined ${this.confName}`);
|
this.logger.debug({memberId, confUuid}, `Conference:_joinConference: successfully joined ${this.confName}`);
|
||||||
this.memberId = memberId;
|
this.memberId = memberId;
|
||||||
this.confUuid = confUuid;
|
this.confUuid = confUuid;
|
||||||
|
|
||||||
cs.setConferenceDetails(memberId, this.confName, confUuid);
|
|
||||||
const response = await this.ep.api('conference', [this.confName, 'get', 'count']);
|
const response = await this.ep.api('conference', [this.confName, 'get', 'count']);
|
||||||
if (response.body && /\d+/.test(response.body)) this.participantCount = parseInt(response.body);
|
if (response.body && /\d+/.test(response.body)) this.participantCount = parseInt(response.body);
|
||||||
this._notifyConferenceEvent(cs, 'join');
|
this._notifyConferenceEvent(cs, 'join');
|
||||||
|
|
||||||
// start recording if requested and we just started the conference
|
|
||||||
if (startConf && this.shouldRecord) {
|
|
||||||
this.logger.info(`recording conference to ${this.record.path}`);
|
|
||||||
try {
|
|
||||||
await this.ep.api(`conference ${this.confName} record ${this.record.path}`);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info({err}, 'Conference:_joinConference - failed to start recording');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// listen for conference events
|
// listen for conference events
|
||||||
this.ep.filter('Conference-Unique-ID', this.confUuid);
|
this.ep.filter('Conference-Unique-ID', this.confUuid);
|
||||||
this.ep.conn.on('esl::event::CUSTOM::*', this.__onConferenceEvent.bind(this, cs)) ;
|
this.ep.conn.on('esl::event::CUSTOM::*', this.__onConferenceEvent.bind(this, cs)) ;
|
||||||
@@ -390,14 +356,9 @@ class Conference extends Task {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof this.maxParticipants === 'number' && this.maxParticipants > 1) {
|
if (typeof this.maxParticipants === 'number' && this.maxParticipants > 1) {
|
||||||
this.ep.api('conference', `${this.confName} set max_members ${this.maxParticipants}`)
|
this.endpoint.api('conference', `${this.confName} set max_members ${this.maxParticipants}`)
|
||||||
.catch((err) => this.logger.error(err, `Error setting max participants to ${this.maxParticipants}`));
|
.catch((err) => this.logger.error(err, `Error setting max participants to ${this.maxParticipants}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof this.endConferenceDuration === 'number' && this.endConferenceDuration >= 0) {
|
|
||||||
this.ep.api('conference', `${this.confName} set endconference_grace_time ${this.endConferenceDuration}`)
|
|
||||||
.catch((err) => this.logger.error(err, `Error setting end conference time to ${this.endConferenceDuration}`));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -410,78 +371,9 @@ class Conference extends Task {
|
|||||||
*/
|
*/
|
||||||
notifyStartConference(cs, opts) {
|
notifyStartConference(cs, opts) {
|
||||||
this.logger.info({opts}, `Conference:notifyStartConference: conference ${this.confName} has now started`);
|
this.logger.info({opts}, `Conference:notifyStartConference: conference ${this.confName} has now started`);
|
||||||
this.conferenceStartTime = new Date();
|
|
||||||
this.emitter.emit('join', opts);
|
this.emitter.emit('join', opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
async doConferenceMuteNonModerators(cs, opts) {
|
|
||||||
const mute = opts.conf_mute_status === 'mute';
|
|
||||||
assert (cs.isInConference);
|
|
||||||
|
|
||||||
this.logger.info(`Conference:doConferenceMuteNonModerators ${mute ? 'muting' : 'unmuting'} non-moderators`);
|
|
||||||
this.ep.api(`conference ${this.confName} ${mute ? 'mute' : 'unmute'} non_moderator`)
|
|
||||||
.catch((err) => this.logger.info({err}, 'Error muting or unmuting non_moderators'));
|
|
||||||
|
|
||||||
if (this.conf_hold_status !== 'hold' && this._playSession) {
|
|
||||||
this._playSession.kill();
|
|
||||||
this._playSession = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async doConferenceHold(cs, opts) {
|
|
||||||
assert (cs.isInConference);
|
|
||||||
|
|
||||||
const {conf_hold_status, wait_hook} = opts;
|
|
||||||
let hookOnly = true;
|
|
||||||
|
|
||||||
if (this.conf_hold_status !== conf_hold_status) {
|
|
||||||
hookOnly = false;
|
|
||||||
this.conf_hold_status = conf_hold_status;
|
|
||||||
const hold = conf_hold_status === 'hold';
|
|
||||||
|
|
||||||
this.ep.api(`conference ${this.confName} ${hold ? 'mute' : 'unmute'} ${this.memberId}`)
|
|
||||||
.catch((err) => this.logger.info({err}, 'Error muting or unmuting participant'));
|
|
||||||
this.ep.api(`conference ${this.confName} ${hold ? 'deaf' : 'undeaf'} ${this.memberId}`)
|
|
||||||
.catch((err) => this.logger.info({err}, 'Error deafing or undeafing participant'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (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 (this.wait_hook?.url && this.conf_hold_status === 'hold') {
|
|
||||||
const {dlg} = cs;
|
|
||||||
this._doWaitHookWhileOnHold(cs, dlg, this.wait_hook);
|
|
||||||
}
|
|
||||||
else if (this.conf_hold_status !== 'hold' && this._playSession) {
|
|
||||||
this._playSession.kill();
|
|
||||||
this._playSession = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _doWaitHookWhileOnHold(cs, dlg, wait_hook) {
|
|
||||||
do {
|
|
||||||
try {
|
|
||||||
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) {
|
|
||||||
this.logger.info(err, `Conference:_doWait: failed retrieving wait_hook for ${this.confName}`);
|
|
||||||
}
|
|
||||||
this._playSession = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} while (!this.killed && this.conf_hold_status === 'hold');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add ourselves to the waitlist of sessions to be notified once
|
* Add ourselves to the waitlist of sessions to be notified once
|
||||||
* the conference starts
|
* the conference starts
|
||||||
@@ -555,33 +447,24 @@ class Conference extends Task {
|
|||||||
|
|
||||||
async _playHook(cs, dlg, hook, allowed = [TaskName.Play, TaskName.Say, TaskName.Pause]) {
|
async _playHook(cs, dlg, hook, allowed = [TaskName.Play, TaskName.Say, TaskName.Pause]) {
|
||||||
assert(!this._playSession);
|
assert(!this._playSession);
|
||||||
const b3 = this.getTracingPropagation();
|
const json = await cs.application.requestor.request(hook, cs.callInfo);
|
||||||
const httpHeaders = b3 && {b3};
|
|
||||||
const json = await cs.application.requestor.request('verb:hook', hook, cs.callInfo, httpHeaders);
|
|
||||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
|
||||||
|
|
||||||
const allowedTasks = tasks.filter((t) => allowed.includes(t.name));
|
const allowedTasks = json.filter((task) => allowed.includes(task.verb));
|
||||||
if (tasks.length !== allowedTasks.length) {
|
if (json.length !== allowedTasks.length) {
|
||||||
this.logger.debug({tasks, allowedTasks}, 'unsupported task');
|
this.logger.debug({json, allowedTasks}, 'unsupported task');
|
||||||
throw new Error(`unsupported verb in conference waitHook: only ${JSON.stringify(allowed)}`);
|
throw new Error(`unsupported verb in dial conference wait/enterHook: only ${JSON.stringify(allowed)}`);
|
||||||
}
|
}
|
||||||
this.logger.debug(`Conference:_playHook: executing ${tasks.length} tasks`);
|
this.logger.debug(`Conference:_playHook: executing ${json.length} tasks`);
|
||||||
|
|
||||||
/* we might have been killed while off fetching waitHook */
|
if (json.length > 0) {
|
||||||
if (this.killed) return [];
|
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||||
|
|
||||||
if (tasks.length > 0) {
|
|
||||||
this._playSession = new ConfirmCallSession({
|
this._playSession = new ConfirmCallSession({
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
application: cs.application,
|
application: cs.application,
|
||||||
dlg,
|
dlg,
|
||||||
ep: cs.ep,
|
ep: cs.ep,
|
||||||
callInfo: cs.callInfo,
|
callInfo: cs.callInfo,
|
||||||
accountInfo: cs.accountInfo,
|
tasks
|
||||||
memberId: this.memberId,
|
|
||||||
confName: this.confName,
|
|
||||||
tasks,
|
|
||||||
rootSpan: cs.rootSpan
|
|
||||||
});
|
});
|
||||||
await this._playSession.exec();
|
await this._playSession.exec();
|
||||||
this._playSession = null;
|
this._playSession = null;
|
||||||
@@ -597,15 +480,10 @@ class Conference extends Task {
|
|||||||
*/
|
*/
|
||||||
_kicked(cs, dlg) {
|
_kicked(cs, dlg) {
|
||||||
this.logger.info(`Conference:kicked - I was dropped from conference ${this.confName}, task is complete`);
|
this.logger.info(`Conference:kicked - I was dropped from conference ${this.confName}, task is complete`);
|
||||||
if (this._playSession) {
|
|
||||||
this._playSession.kill();
|
|
||||||
this._playSession = null;
|
|
||||||
}
|
|
||||||
this.replaceEndpointAndEnd(cs);
|
this.replaceEndpointAndEnd(cs);
|
||||||
}
|
}
|
||||||
|
|
||||||
async replaceEndpointAndEnd(cs) {
|
async replaceEndpointAndEnd(cs) {
|
||||||
cs.clearConferenceDetails();
|
|
||||||
if (this.replaced) return;
|
if (this.replaced) return;
|
||||||
this.replaced = true;
|
this.replaced = true;
|
||||||
try {
|
try {
|
||||||
@@ -618,14 +496,11 @@ class Conference extends Task {
|
|||||||
|
|
||||||
_notifyConferenceEvent(cs, eventName, params = {}) {
|
_notifyConferenceEvent(cs, eventName, params = {}) {
|
||||||
if (this.statusEvents.includes(eventName)) {
|
if (this.statusEvents.includes(eventName)) {
|
||||||
const b3 = this.getTracingPropagation();
|
|
||||||
const httpHeaders = b3 && {b3};
|
|
||||||
params.event = eventName;
|
params.event = eventName;
|
||||||
params.duration = (Date.now() - this.conferenceStartTime.getTime()) / 1000;
|
params.duration = (Date.now() - this.conferenceStartTime.getTime()) / 1000;
|
||||||
if (!params.time) params.time = (new Date()).toISOString();
|
if (!params.time) params.time = (new Date()).toISOString();
|
||||||
if (!params.members && typeof this.participantCount === 'number') params.members = this.participantCount;
|
if (!params.members && typeof this.participantCount === 'number') params.members = this.participantCount;
|
||||||
cs.application.requestor
|
cs.application.requestor.request(this.statusHook, Object.assign(params, this.statusParams))
|
||||||
.request('verb:hook', this.statusHook, Object.assign(params, this.statusParams, httpHeaders))
|
|
||||||
.catch((err) => this.logger.info(err, 'Conference:notifyConferenceEvent - error'));
|
.catch((err) => this.logger.info(err, 'Conference:notifyConferenceEvent - error'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -639,6 +514,9 @@ class Conference extends Task {
|
|||||||
const functionName = `_on${capitalize(camelize(action))}`;
|
const functionName = `_on${capitalize(camelize(action))}`;
|
||||||
(Conference.prototype[functionName] || unhandled).bind(this, this.logger, cs, evt)() ;
|
(Conference.prototype[functionName] || unhandled).bind(this, this.logger, cs, evt)() ;
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
this.logger.debug(`Conference#__onConferenceEvent: got unhandled custom event: ${eventName}`) ;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// conference event handlers
|
// conference event handlers
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
const Task = require('./task');
|
|
||||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
|
||||||
|
|
||||||
class TaskConfig extends Task {
|
|
||||||
constructor(logger, opts) {
|
|
||||||
super(logger, opts);
|
|
||||||
[
|
|
||||||
'synthesizer',
|
|
||||||
'recognizer',
|
|
||||||
'bargeIn',
|
|
||||||
'record',
|
|
||||||
'listen',
|
|
||||||
'transcribe'
|
|
||||||
].forEach((k) => this[k] = this.data[k] || {});
|
|
||||||
|
|
||||||
if ('notifyEvents' in this.data) {
|
|
||||||
this.notifyEvents = !!this.data.notifyEvents;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.bargeIn.enable) {
|
|
||||||
this.gatherOpts = {
|
|
||||||
verb: 'gather',
|
|
||||||
timeout: 0,
|
|
||||||
bargein: true,
|
|
||||||
input: ['speech']
|
|
||||||
};
|
|
||||||
[
|
|
||||||
'finishOnKey', 'input', 'numDigits', 'minDigits', 'maxDigits',
|
|
||||||
'interDigitTimeout', 'bargein', 'dtmfBargein', 'minBargeinWordCount', 'actionHook'
|
|
||||||
].forEach((k) => {
|
|
||||||
if (this.bargeIn[k]) this.gatherOpts[k] = this.bargeIn[k];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (this.transcribe?.enable) {
|
|
||||||
this.transcribeOpts = {
|
|
||||||
verb: 'transcribe',
|
|
||||||
...this.transcribe
|
|
||||||
};
|
|
||||||
delete this.transcribeOpts.enable;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ||
|
|
||||||
this.record?.action ||
|
|
||||||
this.listen?.url ||
|
|
||||||
this.data.amd ||
|
|
||||||
this.transcribe?.enable) ?
|
|
||||||
TaskPreconditions.Endpoint :
|
|
||||||
TaskPreconditions.None;
|
|
||||||
|
|
||||||
this.onHoldMusic = this.data.onHoldMusic;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 hasTranscribe() { return Object.keys(this.transcribe).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;
|
|
||||||
const s = `{${v},${l},${voice}}`;
|
|
||||||
phrase.push(`set synthesizer${s}`);
|
|
||||||
}
|
|
||||||
if (this.hasRecognizer) {
|
|
||||||
const {vendor:v, language:l} = this.recognizer;
|
|
||||||
const s = `{${v},${l}}`;
|
|
||||||
phrase.push(`set recognizer${s}`);
|
|
||||||
}
|
|
||||||
if (this.hasRecording) phrase.push(this.record.action);
|
|
||||||
if (this.hasListen) {
|
|
||||||
phrase.push(this.listen.enable ? `listen ${this.listen.url}` : 'stop listen');
|
|
||||||
}
|
|
||||||
if (this.hasTranscribe) {
|
|
||||||
phrase.push(this.transcribe.enable ? `transcribe ${this.transcribe.transcriptionHook}` : 'stop transcribe');
|
|
||||||
}
|
|
||||||
if (this.data.amd) phrase.push('enable amd');
|
|
||||||
if (this.notifyEvents) phrase.push(`event notification ${this.notifyEvents ? 'on' : 'off'}`);
|
|
||||||
if (this.onHoldMusic) phrase.push(`onHoldMusic: ${this.onHoldMusic}`);
|
|
||||||
return `${this.name}{${phrase.join(',')}}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async exec(cs, {ep} = {}) {
|
|
||||||
await super.exec(cs);
|
|
||||||
|
|
||||||
if (this.notifyEvents) {
|
|
||||||
this.logger.debug(`turning event notification ${this.notifyEvents ? 'on' : 'off'}`);
|
|
||||||
cs.notifyEvents = !!this.data.notifyEvents;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.onHoldMusic) {
|
|
||||||
cs.onHoldMusic = this.onHoldMusic;
|
|
||||||
}
|
|
||||||
|
|
||||||
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.synthesizer = this.synthesizer;
|
|
||||||
cs.speechSynthesisVendor = this.synthesizer.vendor !== 'default'
|
|
||||||
? this.synthesizer.vendor
|
|
||||||
: cs.speechSynthesisVendor;
|
|
||||||
cs.speechSynthesisLabel = this.synthesizer.label !== 'default'
|
|
||||||
? this.synthesizer.label
|
|
||||||
: cs.speechSynthesisLabel;
|
|
||||||
cs.speechSynthesisLanguage = this.synthesizer.language !== 'default'
|
|
||||||
? this.synthesizer.language
|
|
||||||
: cs.speechSynthesisLanguage;
|
|
||||||
cs.speechSynthesisVoice = this.synthesizer.voice !== 'default'
|
|
||||||
? this.synthesizer.voice
|
|
||||||
: cs.speechSynthesisVoice;
|
|
||||||
|
|
||||||
// fallback vendor
|
|
||||||
cs.fallbackSpeechSynthesisVendor = this.synthesizer.fallbackVendor !== 'default'
|
|
||||||
? this.synthesizer.fallbackVendor
|
|
||||||
: cs.fallbackSpeechSynthesisVendor;
|
|
||||||
cs.fallbackSpeechSynthesisLabel = this.synthesizer.fallbackLabel !== 'default'
|
|
||||||
? this.synthesizer.fallbackLabel
|
|
||||||
: cs.fallbackSpeechSynthesisLabel;
|
|
||||||
cs.fallbackSpeechSynthesisLanguage = this.synthesizer.fallbackLanguage !== 'default'
|
|
||||||
? this.synthesizer.fallbackLanguage
|
|
||||||
: cs.fallbackSpeechSynthesisLanguage;
|
|
||||||
cs.fallbackSpeechSynthesisVoice = this.synthesizer.fallbackVoice !== 'default'
|
|
||||||
? this.synthesizer.fallbackVoice
|
|
||||||
: cs.fallbackSpeechSynthesisVoice;
|
|
||||||
this.logger.info({synthesizer: this.synthesizer}, 'Config: updated synthesizer');
|
|
||||||
}
|
|
||||||
if (this.hasRecognizer) {
|
|
||||||
cs.recognizer = this.recognizer;
|
|
||||||
cs.speechRecognizerVendor = this.recognizer.vendor !== 'default'
|
|
||||||
? this.recognizer.vendor
|
|
||||||
: cs.speechRecognizerVendor;
|
|
||||||
cs.speechRecognizerLabel = this.recognizer.label !== 'default'
|
|
||||||
? this.recognizer.label
|
|
||||||
: cs.speechRecognizerLabel;
|
|
||||||
cs.speechRecognizerLanguage = this.recognizer.language !== 'default'
|
|
||||||
? this.recognizer.language
|
|
||||||
: cs.speechRecognizerLanguage;
|
|
||||||
|
|
||||||
//fallback
|
|
||||||
cs.fallbackSpeechRecognizerVendor = this.recognizer.fallbackVendor !== 'default'
|
|
||||||
? this.recognizer.fallbackVendor
|
|
||||||
: cs.fallbackSpeechRecognizerVendor;
|
|
||||||
cs.fallbackSpeechRecognizerLabel = this.recognizer.fallbackLabel !== 'default'
|
|
||||||
? this.recognizer.fallbackLabel
|
|
||||||
: cs.fallbackSpeechRecognizerLabel;
|
|
||||||
cs.fallbackSpeechRecognizerLanguage = this.recognizer.fallbackLanguage !== 'default'
|
|
||||||
? this.recognizer.fallbackLanguage
|
|
||||||
: cs.fallbackSpeechRecognizerLanguage;
|
|
||||||
|
|
||||||
cs.isContinuousAsr = typeof this.recognizer.asrTimeout === 'number' ? true : false;
|
|
||||||
if (cs.isContinuousAsr) {
|
|
||||||
cs.asrTimeout = this.recognizer.asrTimeout;
|
|
||||||
cs.asrDtmfTerminationDigit = this.recognizer.asrDtmfTerminationDigit;
|
|
||||||
}
|
|
||||||
if (Array.isArray(this.recognizer.hints)) {
|
|
||||||
const obj = {hints: this.recognizer.hints};
|
|
||||||
if (typeof this.recognizer.hintsBoost === 'number') {
|
|
||||||
obj.hintsBoost = this.recognizer.hintsBoost;
|
|
||||||
}
|
|
||||||
cs.globalSttHints = obj;
|
|
||||||
}
|
|
||||||
if (Array.isArray(this.recognizer.altLanguages)) {
|
|
||||||
this.logger.info({altLanguages: this.recognizer.altLanguages}, 'Config: updated altLanguages');
|
|
||||||
cs.altLanguages = this.recognizer.altLanguages;
|
|
||||||
}
|
|
||||||
if ('punctuation' in this.recognizer) {
|
|
||||||
cs.globalSttPunctuation = this.recognizer.punctuation;
|
|
||||||
}
|
|
||||||
this.logger.info({
|
|
||||||
recognizer: this.recognizer,
|
|
||||||
isContinuousAsr: cs.isContinuousAsr
|
|
||||||
}, 'Config: updated recognizer');
|
|
||||||
}
|
|
||||||
if ('enable' in this.bargeIn) {
|
|
||||||
if (this.bargeIn.enable === true && this.gatherOpts) {
|
|
||||||
this.gatherOpts.recognizer = this.hasRecognizer ?
|
|
||||||
this.recognizer :
|
|
||||||
{
|
|
||||||
vendor: cs.speechRecognizerVendor,
|
|
||||||
language: cs.speechRecognizerLanguage
|
|
||||||
};
|
|
||||||
this.logger.info({opts: this.gatherOpts}, 'Config: enabling bargeIn');
|
|
||||||
cs.enableBotMode(this.gatherOpts, this.autoEnable);
|
|
||||||
}
|
|
||||||
else if (this.bargeIn.enable === false) {
|
|
||||||
this.logger.info('Config: disabling bargeIn');
|
|
||||||
cs.disableBotMode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.record.action) {
|
|
||||||
try {
|
|
||||||
await cs.notifyRecordOptions(this.record);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info({err}, 'Config: error starting recording');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.hasListen) {
|
|
||||||
const {enable, ...opts} = this.listen;
|
|
||||||
if (enable) {
|
|
||||||
this.logger.debug({opts}, 'Config: enabling listen');
|
|
||||||
cs.startBackgroundTask('listen', {verb: 'listen', ...opts});
|
|
||||||
} else {
|
|
||||||
this.logger.info('Config: disabling listen');
|
|
||||||
cs.stopBackgroundTask('listen');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.hasTranscribe) {
|
|
||||||
if (this.transcribe.enable) {
|
|
||||||
this.transcribeOpts.recognizer = this.hasRecognizer ?
|
|
||||||
this.recognizer :
|
|
||||||
{
|
|
||||||
vendor: cs.speechRecognizerVendor,
|
|
||||||
language: cs.speechRecognizerLanguage
|
|
||||||
};
|
|
||||||
this.logger.debug(this.transcribeOpts, 'Config: enabling transcribe');
|
|
||||||
cs.startBackgroundTask('transcribe', this.transcribeOpts);
|
|
||||||
} else {
|
|
||||||
this.logger.info('Config: disabling transcribe');
|
|
||||||
cs.stopBackgroundTask('transcribe');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.data.sipRequestWithinDialogHook) {
|
|
||||||
cs.sipRequestWithinDialogHook = this.data.sipRequestWithinDialogHook;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async kill(cs) {
|
|
||||||
super.kill(cs);
|
|
||||||
//if (this.ep && this.stopAmd) this.stopAmd(this.ep, this);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onAmdEvent(cs, evt) {
|
|
||||||
this.logger.info({evt}, 'Config:_onAmdEvent');
|
|
||||||
const {actionHook} = this.data.amd;
|
|
||||||
this.performHook(cs, actionHook, evt)
|
|
||||||
.catch((err) => {
|
|
||||||
this.logger.error({err}, 'Config:_onAmdEvent - error calling actionHook');
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = TaskConfig;
|
|
||||||
@@ -16,7 +16,6 @@ class TaskDequeue extends Task {
|
|||||||
this.queueName = this.data.name;
|
this.queueName = this.data.name;
|
||||||
this.timeout = this.data.timeout || 0;
|
this.timeout = this.data.timeout || 0;
|
||||||
this.beep = this.data.beep === true;
|
this.beep = this.data.beep === true;
|
||||||
this.callSid = this.data.callSid;
|
|
||||||
|
|
||||||
this.emitter = new Emitter();
|
this.emitter = new Emitter();
|
||||||
this.state = DequeueResults.Timeout;
|
this.state = DequeueResults.Timeout;
|
||||||
@@ -24,7 +23,7 @@ class TaskDequeue extends Task {
|
|||||||
|
|
||||||
get name() { return TaskName.Dequeue; }
|
get name() { return TaskName.Dequeue; }
|
||||||
|
|
||||||
async exec(cs, {ep}) {
|
async exec(cs, ep) {
|
||||||
await super.exec(cs);
|
await super.exec(cs);
|
||||||
this.ep = ep;
|
this.ep = ep;
|
||||||
this.queueName = `queue:${cs.accountSid}:${this.queueName}`;
|
this.queueName = `queue:${cs.accountSid}:${this.queueName}`;
|
||||||
@@ -54,7 +53,7 @@ class TaskDequeue extends Task {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_getMemberFromQueue(cs) {
|
_getMemberFromQueue(cs) {
|
||||||
const {retrieveFromSortedSet, retrieveByPatternSortedSet} = cs.srf.locals.dbHelpers;
|
const {popFront} = cs.srf.locals.dbHelpers;
|
||||||
|
|
||||||
return new Promise(async(resolve) => {
|
return new Promise(async(resolve) => {
|
||||||
let timer;
|
let timer;
|
||||||
@@ -71,12 +70,7 @@ class TaskDequeue extends Task {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
try {
|
try {
|
||||||
let url;
|
const url = await popFront(this.queueName);
|
||||||
if (this.callSid) {
|
|
||||||
url = await retrieveByPatternSortedSet(this.queueName, `*${this.callSid}`);
|
|
||||||
} else {
|
|
||||||
url = await retrieveFromSortedSet(this.queueName);
|
|
||||||
}
|
|
||||||
if (url) {
|
if (url) {
|
||||||
found = true;
|
found = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
@@ -84,7 +78,7 @@ class TaskDequeue extends Task {
|
|||||||
resolve(url);
|
resolve(url);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.debug({err}, 'TaskDequeue:_getMemberFromQueue error Sorted Set');
|
this.logger.debug({err}, 'TaskDequeue:_getMemberFromQueue error popFront');
|
||||||
}
|
}
|
||||||
await sleepFor(5000);
|
await sleepFor(5000);
|
||||||
} while (!this.killed && !timedout && !found);
|
} while (!this.killed && !timedout && !found);
|
||||||
@@ -116,8 +110,7 @@ class TaskDequeue extends Task {
|
|||||||
event: 'dequeue',
|
event: 'dequeue',
|
||||||
dequeueSipAddress: cs.srf.locals.localSipAddress,
|
dequeueSipAddress: cs.srf.locals.localSipAddress,
|
||||||
epUuid: ep.uuid,
|
epUuid: ep.uuid,
|
||||||
notifyUrl: getUrl(cs),
|
notifyUrl: getUrl(cs)
|
||||||
dequeuer: cs.callInfo.toJSON()
|
|
||||||
});
|
});
|
||||||
this.logger.info(`TaskDequeue:_dequeueUrl successfully sent POST to ${url}`);
|
this.logger.info(`TaskDequeue:_dequeueUrl successfully sent POST to ${url}`);
|
||||||
bridgeTimer = setTimeout(() => reject(new Error('bridge timeout')), 20000);
|
bridgeTimer = setTimeout(() => reject(new Error('bridge timeout')), 20000);
|
||||||
|
|||||||
@@ -1,24 +1,11 @@
|
|||||||
const Task = require('./task');
|
const Task = require('./task');
|
||||||
const makeTask = require('./make_task');
|
const makeTask = require('./make_task');
|
||||||
const {
|
const {CallStatus, CallDirection, TaskName, TaskPreconditions, MAX_SIMRINGS} = require('../utils/constants');
|
||||||
CallStatus,
|
|
||||||
CallDirection,
|
|
||||||
TaskName,
|
|
||||||
TaskPreconditions,
|
|
||||||
MAX_SIMRINGS,
|
|
||||||
KillReason
|
|
||||||
} = require('../utils/constants');
|
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
const placeCall = require('../utils/place-outdial');
|
const placeCall = require('../utils/place-outdial');
|
||||||
const sessionTracker = require('../session/session-tracker');
|
const sessionTracker = require('../session/session-tracker');
|
||||||
const DtmfCollector = require('../utils/dtmf-collector');
|
const DtmfCollector = require('../utils/dtmf-collector');
|
||||||
const ConfirmCallSession = require('../session/confirm-call-session');
|
|
||||||
const dbUtils = require('../utils/db-utils');
|
|
||||||
const debug = require('debug')('jambonz:feature-server');
|
const debug = require('debug')('jambonz:feature-server');
|
||||||
const {parseUri} = require('drachtio-srf');
|
|
||||||
const {ANCHOR_MEDIA_ALWAYS, JAMBONZ_DISABLE_DIAL_PAI_HEADER} = require('../config');
|
|
||||||
const { isOnhold, isOpusFirst } = require('../utils/sdp-utils');
|
|
||||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
|
||||||
|
|
||||||
function parseDtmfOptions(logger, dtmfCapture) {
|
function parseDtmfOptions(logger, dtmfCapture) {
|
||||||
let parentDtmfCollector, childDtmfCollector;
|
let parentDtmfCollector, childDtmfCollector;
|
||||||
@@ -88,7 +75,6 @@ class TaskDial extends Task {
|
|||||||
|
|
||||||
this.earlyMedia = this.data.answerOnBridge === true;
|
this.earlyMedia = this.data.answerOnBridge === true;
|
||||||
this.callerId = this.data.callerId;
|
this.callerId = this.data.callerId;
|
||||||
this.callerName = this.data.callerName;
|
|
||||||
this.dialMusic = this.data.dialMusic;
|
this.dialMusic = this.data.dialMusic;
|
||||||
this.headers = this.data.headers || {};
|
this.headers = this.data.headers || {};
|
||||||
this.method = this.data.method || 'POST';
|
this.method = this.data.method || 'POST';
|
||||||
@@ -97,7 +83,6 @@ class TaskDial extends Task {
|
|||||||
this.timeLimit = this.data.timeLimit;
|
this.timeLimit = this.data.timeLimit;
|
||||||
this.confirmHook = this.data.confirmHook;
|
this.confirmHook = this.data.confirmHook;
|
||||||
this.confirmMethod = this.data.confirmMethod;
|
this.confirmMethod = this.data.confirmMethod;
|
||||||
this.referHook = this.data.referHook;
|
|
||||||
this.dtmfHook = this.data.dtmfHook;
|
this.dtmfHook = this.data.dtmfHook;
|
||||||
this.proxy = this.data.proxy;
|
this.proxy = this.data.proxy;
|
||||||
|
|
||||||
@@ -128,67 +113,14 @@ class TaskDial extends Task {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get ep() {
|
get ep() {
|
||||||
/**
|
|
||||||
* Note:
|
|
||||||
* this.ep is the B leg-facing endpoint
|
|
||||||
* this.epOther is the A leg-facing endpoint
|
|
||||||
*/
|
|
||||||
if (this.sd) return this.sd.ep;
|
if (this.sd) return this.sd.ep;
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() { return TaskName.Dial; }
|
get name() { return TaskName.Dial; }
|
||||||
|
|
||||||
get isOnHold() {
|
|
||||||
return this.isIncomingLegHold || this.isOutgoingLegHold;
|
|
||||||
}
|
|
||||||
|
|
||||||
get canReleaseMedia() {
|
|
||||||
const keepAnchor = this.data.anchorMedia ||
|
|
||||||
this.cs.isBackGroundListen ||
|
|
||||||
ANCHOR_MEDIA_ALWAYS ||
|
|
||||||
this.listenTask ||
|
|
||||||
this.transcribeTask ||
|
|
||||||
this.startAmd;
|
|
||||||
|
|
||||||
return !keepAnchor;
|
|
||||||
}
|
|
||||||
|
|
||||||
get summary() {
|
|
||||||
if (this.target.length === 1) {
|
|
||||||
const target = this.target[0];
|
|
||||||
switch (target.type) {
|
|
||||||
case 'phone':
|
|
||||||
case 'teams':
|
|
||||||
return `${this.name}{type=${target.type},number=${target.number}}`;
|
|
||||||
case 'user':
|
|
||||||
return `${this.name}{type=${target.type},name=${target.name}}`;
|
|
||||||
case 'sip':
|
|
||||||
return `${this.name}{type=${target.type},sipUri=${target.sipUri}}`;
|
|
||||||
default:
|
|
||||||
return `${this.name}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else return `${this.name}{${this.target.length} targets}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async exec(cs) {
|
async exec(cs) {
|
||||||
await super.exec(cs);
|
await super.exec(cs);
|
||||||
try {
|
try {
|
||||||
if (this.listenTask) {
|
|
||||||
const {span, ctx} = this.startChildSpan(`nested:${this.listenTask.summary}`);
|
|
||||||
this.listenTask.span = span;
|
|
||||||
this.listenTask.ctx = ctx;
|
|
||||||
}
|
|
||||||
if (this.transcribeTask) {
|
|
||||||
const {span, ctx} = this.startChildSpan(`nested:${this.transcribeTask.summary}`);
|
|
||||||
this.transcribeTask.span = span;
|
|
||||||
this.transcribeTask.ctx = ctx;
|
|
||||||
}
|
|
||||||
if (this.data.amd) {
|
|
||||||
this.startAmd = cs.startAmd;
|
|
||||||
this.stopAmd = cs.stopAmd;
|
|
||||||
this.on('amd', this._onAmdEvent.bind(this, cs));
|
|
||||||
}
|
|
||||||
if (cs.direction === CallDirection.Inbound) {
|
if (cs.direction === CallDirection.Inbound) {
|
||||||
await this._initializeInbound(cs);
|
await this._initializeInbound(cs);
|
||||||
}
|
}
|
||||||
@@ -198,58 +130,31 @@ class TaskDial extends Task {
|
|||||||
this.epOther.play(this.dialMusic).catch((err) => {});
|
this.epOther.play(this.dialMusic).catch((err) => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!this.killed) await this._attemptCalls(cs);
|
this._installDtmfDetection(cs, this.epOther, this.parentDtmfCollector);
|
||||||
|
await this._attemptCalls(cs);
|
||||||
await this.awaitTaskDone();
|
await this.awaitTaskDone();
|
||||||
this.logger.debug({callSid: this.cs.callSid}, 'Dial:exec task is done, sending actionHook if any');
|
await this.performAction(this.results);
|
||||||
await this.performAction(this.results, this.killReason !== KillReason.Replaced);
|
this._removeDtmfDetection(cs, this.epOther);
|
||||||
this._removeDtmfDetection(cs.dlg);
|
this._removeDtmfDetection(cs, this.ep);
|
||||||
this._removeDtmfDetection(this.dlg);
|
|
||||||
this._removeSipIndialogRequestListener(this.dlg);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error({err}, 'TaskDial:exec terminating with error');
|
this.logger.error({err}, 'TaskDial:exec terminating with error');
|
||||||
this.kill(cs);
|
this.kill(cs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async kill(cs, reason) {
|
async kill(cs) {
|
||||||
super.kill(cs);
|
super.kill(cs);
|
||||||
try {
|
this._removeDtmfDetection(this.cs, this.epOther);
|
||||||
if (this.ep && this.ep.amd) this.stopAmd(this.ep, this);
|
this._removeDtmfDetection(this.cs, this.ep);
|
||||||
} catch (err) {
|
this._killOutdials();
|
||||||
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'));
|
|
||||||
}
|
|
||||||
this.killReason = reason || KillReason.Hangup;
|
|
||||||
if (this.timerMaxCallDuration) {
|
|
||||||
clearTimeout(this.timerMaxCallDuration);
|
|
||||||
this.timerMaxCallDuration = null;
|
|
||||||
}
|
|
||||||
if (this.timerRing) {
|
|
||||||
clearTimeout(this.timerRing);
|
|
||||||
this.timerRing = null;
|
|
||||||
}
|
|
||||||
this._removeDtmfDetection(cs.dlg);
|
|
||||||
this._removeDtmfDetection(this.dlg);
|
|
||||||
await this._killOutdials();
|
|
||||||
if (this.sd) {
|
if (this.sd) {
|
||||||
this.sd.kill();
|
this.sd.kill();
|
||||||
this.sd.removeAllListeners();
|
|
||||||
this.sd = null;
|
this.sd = null;
|
||||||
}
|
}
|
||||||
if (this.callSid) sessionTracker.remove(this.callSid);
|
if (this.callSid) sessionTracker.remove(this.callSid);
|
||||||
if (this.listenTask) {
|
if (this.listenTask) await this.listenTask.kill(cs);
|
||||||
await this.listenTask.kill(cs);
|
if (this.transcribeTask) await this.transcribeTask.kill(cs);
|
||||||
this.listenTask.span.end();
|
if (this.timerMaxCallDuration) clearTimeout(this.timerMaxCallDuration);
|
||||||
this.listenTask = null;
|
|
||||||
}
|
|
||||||
if (this.transcribeTask) {
|
|
||||||
await this.transcribeTask.kill(cs);
|
|
||||||
this.transcribeTask.span.end();
|
|
||||||
this.transcribeTask = null;
|
|
||||||
}
|
|
||||||
this.notifyTaskDone();
|
this.notifyTaskDone();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,32 +163,18 @@ class TaskDial extends Task {
|
|||||||
* @param {*} tasks - array of play/say tasks to execute
|
* @param {*} tasks - array of play/say tasks to execute
|
||||||
*/
|
*/
|
||||||
async whisper(tasks, callSid) {
|
async whisper(tasks, callSid) {
|
||||||
|
if (!this.epOther || !this.ep) return this.logger.info('Dial:whisper: no paired endpoint found');
|
||||||
try {
|
try {
|
||||||
const cs = this.callSession;
|
const cs = this.callSession;
|
||||||
if (!this.ep && !this.epOther) {
|
|
||||||
await this.reAnchorMedia(this.callSession, this.sd);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.epOther || !this.ep) return this.logger.info('Dial:whisper: no paired endpoint found');
|
|
||||||
|
|
||||||
this.logger.debug('Dial:whisper unbridging endpoints');
|
this.logger.debug('Dial:whisper unbridging endpoints');
|
||||||
await this.epOther.unbridge();
|
await this.epOther.unbridge();
|
||||||
this.logger.debug('Dial:whisper executing tasks');
|
this.logger.debug('Dial:whisper executing tasks');
|
||||||
while (tasks.length && !cs.callGone) {
|
while (tasks.length && !cs.callGone) {
|
||||||
const task = tasks.shift();
|
const task = tasks.shift();
|
||||||
const {span, ctx} = this.startChildSpan(`whisper:${task.summary}`);
|
await task.exec(cs, callSid === this.callSid ? this.ep : this.epOther);
|
||||||
task.span = span;
|
|
||||||
task.ctx = ctx;
|
|
||||||
await task.exec(cs, callSid === this.callSid ? {ep: this.ep} : {ep: this.epOther});
|
|
||||||
span.end();
|
|
||||||
}
|
}
|
||||||
this.logger.debug('Dial:whisper tasks complete');
|
this.logger.debug('Dial:whisper tasks complete');
|
||||||
if (!cs.callGone && this.epOther) {
|
if (!cs.callGone) this.epOther.bridge(this.ep);
|
||||||
|
|
||||||
/* if we can release the media back to the SBC, do so now */
|
|
||||||
if (this.canReleaseMedia) this._releaseMedia(cs, this.sd);
|
|
||||||
else this.epOther.bridge(this.ep);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(err, 'Dial:whisper error');
|
this.logger.error(err, 'Dial:whisper error');
|
||||||
}
|
}
|
||||||
@@ -293,168 +184,56 @@ class TaskDial extends Task {
|
|||||||
* mute or unmute one side of the call
|
* mute or unmute one side of the call
|
||||||
*/
|
*/
|
||||||
async mute(callSid, doMute) {
|
async mute(callSid, doMute) {
|
||||||
const parentCall = callSid !== this.callSid;
|
if (!this.epOther || !this.ep) return this.logger.info('Dial:mute: no paired endpoint found');
|
||||||
const dlg = parentCall ? this.callSession.dlg : this.dlg;
|
|
||||||
const hdr = `${doMute ? 'mute' : 'unmute'} call leg`;
|
|
||||||
try {
|
try {
|
||||||
/* let rtpengine do the mute / unmute */
|
const parentCall = callSid !== this.callSid;
|
||||||
await dlg.request({
|
const ep = parentCall ? this.epOther : this.ep;
|
||||||
method: 'INFO',
|
await ep[doMute ? 'mute' : 'unmute']();
|
||||||
headers: {
|
this.logger.debug(`Dial:mute ${doMute ? 'muted' : 'unmuted'} ${parentCall ? 'parentCall' : 'childCall'}`);
|
||||||
'X-Reason': hdr
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.info({err}, `Dial:mute - ${hdr} error`);
|
this.logger.error(err, 'Dial:mute error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleRefer(cs, req, res, callInfo = cs.callInfo) {
|
_killOutdials() {
|
||||||
if (this.referHook) {
|
|
||||||
try {
|
|
||||||
const isChild = !!callInfo.parentCallSid;
|
|
||||||
const referring_call_sid = isChild ? callInfo.callSid : cs.callSid;
|
|
||||||
const referred_call_sid = isChild ? callInfo.parentCallSid : this.sd.callSid;
|
|
||||||
|
|
||||||
const b3 = this.getTracingPropagation();
|
|
||||||
const httpHeaders = b3 && {b3};
|
|
||||||
|
|
||||||
const to = parseUri(req.getParsedHeader('Refer-To').uri);
|
|
||||||
const by = parseUri(req.getParsedHeader('Referred-By').uri);
|
|
||||||
this.logger.info({to}, 'refer to parsed');
|
|
||||||
const json = await cs.requestor.request('verb:hook', this.referHook, {
|
|
||||||
...(callInfo.toJSON()),
|
|
||||||
refer_details: {
|
|
||||||
sip_refer_to: req.get('Refer-To'),
|
|
||||||
sip_referred_by: req.get('Referred-By'),
|
|
||||||
sip_user_agent: req.get('User-Agent'),
|
|
||||||
refer_to_user: to.scheme === 'tel' ? to.number : to.user,
|
|
||||||
referred_by_user: by.scheme === 'tel' ? by.number : by.user,
|
|
||||||
referring_call_sid,
|
|
||||||
referred_call_sid
|
|
||||||
}
|
|
||||||
}, httpHeaders);
|
|
||||||
if (json && Array.isArray(json)) {
|
|
||||||
try {
|
|
||||||
const logger = isChild ? this.logger : this.sd.logger;
|
|
||||||
const tasks = normalizeJambones(logger, json).map((tdata) => makeTask(this.logger, tdata));
|
|
||||||
if (tasks && tasks.length > 0) {
|
|
||||||
const legs = isChild ? ['child', 'parent'] : ['parent', 'child'];
|
|
||||||
logger.info(`Dial:handleRefer received REFER on ${legs[0]} leg, setting new app on ${legs[1]} leg`);
|
|
||||||
if (isChild) this.redirect(cs, tasks);
|
|
||||||
else {
|
|
||||||
logger.info({tasks: json}, 'Dial:handleRefer - new application for for child leg');
|
|
||||||
const adultingSession = await this.sd.doAdulting({
|
|
||||||
logger,
|
|
||||||
application: cs.application,
|
|
||||||
tasks
|
|
||||||
});
|
|
||||||
/* need to update the callSid of the child with its own (new) AdultingCallSession */
|
|
||||||
sessionTracker.add(adultingSession.callSid, adultingSession);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info(err, 'Dial:handleRefer - error setting new application after receiving REFER');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res.send(202);
|
|
||||||
this.logger.info('DialTask:handleRefer - sent 202 Accepted');
|
|
||||||
} catch (err) {
|
|
||||||
res.send(err.statusCode || 501);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.logger.info('DialTask:handleRefer - got REFER but no referHook, responding 501');
|
|
||||||
res.send(501);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_removeHandlers(sd) {
|
|
||||||
sd.removeAllListeners('accept');
|
|
||||||
sd.removeAllListeners('decline');
|
|
||||||
sd.removeAllListeners('adulting');
|
|
||||||
sd.removeAllListeners('callStatusChange');
|
|
||||||
sd.removeAllListeners('callCreateFail');
|
|
||||||
}
|
|
||||||
|
|
||||||
async _killOutdials() {
|
|
||||||
for (const [callSid, sd] of Array.from(this.dials)) {
|
for (const [callSid, sd] of Array.from(this.dials)) {
|
||||||
this.logger.debug(`Dial:_killOutdials killing callSid ${callSid}`);
|
this.logger.debug(`Dial:_killOutdials killing callSid ${callSid}`);
|
||||||
try {
|
sd.kill().catch((err) => this.logger.info(err, `Dial:_killOutdials Error killing ${callSid}`));
|
||||||
await sd.kill();
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info(err, `Dial:_killOutdials Error killing ${callSid}`);
|
|
||||||
}
|
|
||||||
this._removeHandlers(sd);
|
|
||||||
this.logger.debug(`Dial:_killOutdials killed callSid ${callSid}`);
|
|
||||||
}
|
}
|
||||||
this.dials.clear();
|
this.dials.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
_installDtmfDetection(cs, dlg) {
|
_installDtmfDetection(cs, ep, dtmfDetector) {
|
||||||
dlg.on('info', this._onInfo.bind(this, cs, dlg));
|
if (ep && this.dtmfHook && !ep.dtmfDetector) {
|
||||||
|
ep.dtmfDetector = dtmfDetector;
|
||||||
|
ep.on('dtmf', this._onDtmf.bind(this, cs, ep));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_removeDtmfDetection(dlg) {
|
_removeDtmfDetection(cs, ep) {
|
||||||
dlg && dlg.removeAllListeners('info');
|
if (ep) {
|
||||||
}
|
delete ep.dtmfDetector;
|
||||||
|
ep.removeAllListeners('dtmf');
|
||||||
_onInfo(cs, dlg, req, res) {
|
|
||||||
// SIP Indialog will be handled by another handler
|
|
||||||
if (cs.sipRequestWithinDialogHook) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.send(200);
|
|
||||||
if (req.get('Content-Type') !== 'application/dtmf-relay') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dtmfDetector = dlg === cs.dlg ? this.parentDtmfCollector : this.childDtmfCollector;
|
|
||||||
if (!dtmfDetector) return;
|
|
||||||
let requestor, callSid, callInfo;
|
|
||||||
if (dtmfDetector === this.parentDtmfCollector) {
|
|
||||||
requestor = cs.requestor;
|
|
||||||
callSid = cs.callSid;
|
|
||||||
callInfo = cs.callInfo;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
requestor = this.sd?.requestor;
|
|
||||||
callSid = this.sd?.callSid;
|
|
||||||
callInfo = this.sd?.callInfo;
|
|
||||||
}
|
|
||||||
if (!requestor) return;
|
|
||||||
const arr = /Signal=([0-9#*])/.exec(req.body);
|
|
||||||
if (!arr) return;
|
|
||||||
const key = arr[1];
|
|
||||||
const match = dtmfDetector.keyPress(key);
|
|
||||||
if (match) {
|
|
||||||
const b3 = this.getTracingPropagation();
|
|
||||||
const httpHeaders = b3 && {b3};
|
|
||||||
this.logger.info({callSid}, `Dial:_onInfo triggered dtmf match: ${match}`);
|
|
||||||
requestor.request('verb:hook', this.dtmfHook, {dtmf: match, ...callInfo.toJSON()}, httpHeaders)
|
|
||||||
.catch((err) => this.logger.info(err, 'Dial:_onDtmf - error'));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_initSipIndialogRequestListener(cs, dlg) {
|
_onDtmf(cs, ep, evt) {
|
||||||
dlg.on('info', this._onRequestWithinDialog.bind(this, cs));
|
if (ep.dtmfDetector) {
|
||||||
dlg.on('message', this._onRequestWithinDialog.bind(this, cs));
|
const match = ep.dtmfDetector.keyPress(evt.dtmf);
|
||||||
}
|
const requestor = ep.dtmfDetector === this.parentDtmfCollector ?
|
||||||
|
cs.requestor :
|
||||||
_removeSipIndialogRequestListener(dlg) {
|
this.sd.requestor;
|
||||||
dlg && dlg.removeAllListeners('message');
|
if (match) {
|
||||||
dlg && dlg.removeAllListeners('info');
|
this.logger.debug(`parentCall triggered dtmf match: ${match}`);
|
||||||
}
|
requestor.request(this.dtmfHook, Object.assign({dtmf: match}, cs.callInfo))
|
||||||
|
.catch((err) => this.logger.info(err, 'Dial:_onDtmf - error'));
|
||||||
async _onRequestWithinDialog(cs, req, res) {
|
}
|
||||||
cs._onRequestWithinDialog(req, res);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _initializeInbound(cs) {
|
async _initializeInbound(cs) {
|
||||||
const {ep} = await cs._evalEndpointPrecondition(this);
|
const ep = await cs._evalEndpointPrecondition(this);
|
||||||
this.epOther = ep;
|
this.epOther = ep;
|
||||||
|
debug(`Dial:__initializeInbound allocated ep for incoming call: ${ep.uuid}`);
|
||||||
/* send outbound legs back to the same SBC (to support static IP feature) */
|
|
||||||
if (!this.proxy) this.proxy = `${cs.req.source_address}:${cs.req.source_port}`;
|
|
||||||
|
|
||||||
if (this.dialMusic) {
|
if (this.dialMusic) {
|
||||||
// play dial music to caller while we outdial
|
// play dial music to caller while we outdial
|
||||||
@@ -467,29 +246,15 @@ class TaskDial extends Task {
|
|||||||
async _attemptCalls(cs) {
|
async _attemptCalls(cs) {
|
||||||
const {req, srf} = cs;
|
const {req, srf} = cs;
|
||||||
const {getSBC} = srf.locals;
|
const {getSBC} = srf.locals;
|
||||||
const {lookupTeamsByAccount, lookupAccountBySid} = srf.locals.dbHelpers;
|
const {lookupTeamsByAccount} = srf.locals.dbHelpers;
|
||||||
const {lookupCarrier, lookupCarrierByPhoneNumber} = dbUtils(this.logger, cs.srf);
|
|
||||||
const sbcAddress = this.proxy || getSBC();
|
const sbcAddress = this.proxy || getSBC();
|
||||||
const teamsInfo = {};
|
const teamsInfo = {};
|
||||||
let fqdn;
|
|
||||||
|
|
||||||
if (!sbcAddress) throw new Error('no SBC found for outbound call');
|
if (!sbcAddress) throw new Error('no SBC found for outbound call');
|
||||||
this.headers = {
|
|
||||||
'X-Account-Sid': cs.accountSid,
|
|
||||||
...(req && req.has('X-CID') && {'X-CID': req.get('X-CID')}),
|
|
||||||
...(req && req.has('P-Asserted-Identity') && !JAMBONZ_DISABLE_DIAL_PAI_HEADER &&
|
|
||||||
{'P-Asserted-Identity': req.get('P-Asserted-Identity')}),
|
|
||||||
...(req && req.has('X-Voip-Carrier-Sid') && {'X-Voip-Carrier-Sid': req.get('X-Voip-Carrier-Sid')}),
|
|
||||||
// Put headers at the end to make sure opt.headers override all default behavior.
|
|
||||||
...this.headers
|
|
||||||
};
|
|
||||||
|
|
||||||
const opts = {
|
const opts = {
|
||||||
headers: this.headers,
|
headers: req && req.has('X-CID') ? Object.assign(this.headers, {'X-CID': req.get('X-CID')}) : this.headers,
|
||||||
proxy: `sip:${sbcAddress}`,
|
proxy: `sip:${sbcAddress}`,
|
||||||
callingNumber: this.callerId || req.callingNumber,
|
callingNumber: this.callerId || req.callingNumber
|
||||||
...(this.callerName && {callingName: this.callerName}),
|
|
||||||
opusFirst: isOpusFirst(this.cs.ep.remote.sdp)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const t = this.target.find((t) => t.type === 'teams');
|
const t = this.target.find((t) => t.type === 'teams');
|
||||||
@@ -500,64 +265,16 @@ class TaskDial extends Task {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ms = await cs.getMS();
|
const ms = await cs.getMS();
|
||||||
this.timerRing = setTimeout(async() => {
|
const timerRing = setTimeout(() => {
|
||||||
this.logger.info(`Dial:_attemptCall: ring no answer timer ${this.timeout}s exceeded`);
|
this.logger.info(`Dial:_attemptCall: ring no answer timer ${this.timeout}s exceeded`);
|
||||||
this.timerRing = null;
|
this._killOutdials();
|
||||||
try {
|
|
||||||
await this._killOutdials();
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info(err, 'Dial:_attemptCall - error killing outdials');
|
|
||||||
}
|
|
||||||
this.result = {
|
|
||||||
dialCallStatus: CallStatus.NoAnswer,
|
|
||||||
dialSipStatus: 487
|
|
||||||
};
|
|
||||||
this.kill(cs);
|
|
||||||
}, this.timeout * 1000);
|
}, this.timeout * 1000);
|
||||||
|
|
||||||
this.span.setAttributes({'dial.target': JSON.stringify(this.target)});
|
this.target.forEach((t) => {
|
||||||
this.target.forEach(async(t) => {
|
|
||||||
try {
|
try {
|
||||||
t.confirmHook = t.confirmHook || this.confirmHook;
|
t.url = t.url || this.confirmUrl;
|
||||||
//t.method = t.method || this.confirmMethod || 'POST';
|
t.method = t.method || this.confirmMethod || 'POST';
|
||||||
if (t.type === 'teams') t.teamsInfo = teamsInfo;
|
if (t.type === 'teams') t.teamsInfo = teamsInfo;
|
||||||
if (t.type === 'user' && !t.name.includes('@') && !fqdn) {
|
|
||||||
const user = t.name;
|
|
||||||
try {
|
|
||||||
const {sip_realm} = await lookupAccountBySid(cs.accountSid);
|
|
||||||
if (sip_realm) {
|
|
||||||
t.name = `${user}@${sip_realm}`;
|
|
||||||
this.logger.debug(`appending sip realm ${sip_realm} to dial target user ${user}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error({err}, 'Error looking up account by sid');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (t.type === 'phone' && t.trunk) {
|
|
||||||
const voip_carrier_sid = await lookupCarrier(cs.accountSid, t.trunk);
|
|
||||||
this.logger.info(`Dial:_attemptCalls: selected ${voip_carrier_sid} for requested carrier: ${t.trunk}`);
|
|
||||||
if (voip_carrier_sid) {
|
|
||||||
opts.headers['X-Requested-Carrier-Sid'] = voip_carrier_sid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* trunk isn't specified,
|
|
||||||
* check if number matches any existing numbers
|
|
||||||
* */
|
|
||||||
if (t.type === 'phone' && !t.trunk) {
|
|
||||||
const str = this.callerId || req.callingNumber || '';
|
|
||||||
const callingNumber = str.startsWith('+') ? str.substring(1) : str;
|
|
||||||
const voip_carrier_sid = await lookupCarrierByPhoneNumber(cs.accountSid, callingNumber);
|
|
||||||
this.logger.info(
|
|
||||||
`Dial:_attemptCalls: selected ${voip_carrier_sid} for requested phone number: ${callingNumber}`);
|
|
||||||
if (voip_carrier_sid) {
|
|
||||||
opts.headers['X-Requested-Carrier-Sid'] = voip_carrier_sid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.killed) return;
|
|
||||||
|
|
||||||
const sd = placeCall({
|
const sd = placeCall({
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
application: cs.application,
|
application: cs.application,
|
||||||
@@ -566,31 +283,22 @@ class TaskDial extends Task {
|
|||||||
sbcAddress,
|
sbcAddress,
|
||||||
target: t,
|
target: t,
|
||||||
opts,
|
opts,
|
||||||
callInfo: cs.callInfo,
|
callInfo: cs.callInfo
|
||||||
accountInfo: cs.accountInfo,
|
|
||||||
rootSpan: cs.rootSpan,
|
|
||||||
startSpan: this.startSpan.bind(this),
|
|
||||||
dialTask: this
|
|
||||||
});
|
});
|
||||||
this.dials.set(sd.callSid, sd);
|
this.dials.set(sd.callSid, sd);
|
||||||
|
|
||||||
sd
|
sd
|
||||||
.on('refer', (callInfo, req, res) => this.handleRefer(cs, req, res, callInfo))
|
|
||||||
.on('callCreateFail', () => {
|
.on('callCreateFail', () => {
|
||||||
clearTimeout(this.timerRing);
|
|
||||||
this.dials.delete(sd.callSid);
|
this.dials.delete(sd.callSid);
|
||||||
sd.removeAllListeners();
|
|
||||||
if (this.dials.size === 0 && !this.sd) {
|
if (this.dials.size === 0 && !this.sd) {
|
||||||
this.logger.debug('Dial:_attemptCalls - all calls failed after call create err, ending task');
|
this.logger.debug('Dial:_attemptCalls - all calls failed after call create err, ending task');
|
||||||
this.kill(cs);
|
this.kill(cs);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on('callStatusChange', (obj) => {
|
.on('callStatusChange', (obj) => {
|
||||||
if (this.results.dialCallStatus !== CallStatus.Completed &&
|
if (this.results.dialCallStatus !== CallStatus.Completed) {
|
||||||
this.results.dialCallStatus !== CallStatus.NoAnswer) {
|
|
||||||
Object.assign(this.results, {
|
Object.assign(this.results, {
|
||||||
dialCallStatus: obj.callStatus,
|
dialCallStatus: obj.callStatus,
|
||||||
dialSipStatus: obj.sipStatus,
|
|
||||||
dialCallSid: sd.callSid,
|
dialCallSid: sd.callSid,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -604,8 +312,7 @@ class TaskDial extends Task {
|
|||||||
break;
|
break;
|
||||||
case CallStatus.InProgress:
|
case CallStatus.InProgress:
|
||||||
this.logger.debug('Dial:_attemptCall -- call was answered');
|
this.logger.debug('Dial:_attemptCall -- call was answered');
|
||||||
clearTimeout(this.timerRing);
|
clearTimeout(timerRing);
|
||||||
this.timerRing = null;
|
|
||||||
break;
|
break;
|
||||||
case CallStatus.Failed:
|
case CallStatus.Failed:
|
||||||
case CallStatus.Busy:
|
case CallStatus.Busy:
|
||||||
@@ -613,47 +320,23 @@ class TaskDial extends Task {
|
|||||||
this.dials.delete(sd.callSid);
|
this.dials.delete(sd.callSid);
|
||||||
if (this.dials.size === 0 && !this.sd) {
|
if (this.dials.size === 0 && !this.sd) {
|
||||||
this.logger.debug('Dial:_attemptCalls - all calls failed after call failure, ending task');
|
this.logger.debug('Dial:_attemptCalls - all calls failed after call failure, ending task');
|
||||||
clearTimeout(this.timerRing);
|
clearTimeout(timerRing);
|
||||||
this.timerRing = null;
|
|
||||||
this.kill(cs);
|
this.kill(cs);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on('accept', async() => {
|
.on('accept', () => {
|
||||||
this.logger.debug(`Dial:_attemptCalls - we have a winner: ${sd.callSid}`);
|
this.logger.debug(`Dial:_attemptCalls - we have a winner: ${sd.callSid}`);
|
||||||
clearTimeout(this.timerRing);
|
this._connectSingleDial(cs, sd);
|
||||||
try {
|
|
||||||
await this._connectSingleDial(cs, sd);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info({err}, 'Dial:_attemptCalls - Error calling _connectSingleDial ');
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.on('decline', () => {
|
.on('decline', () => {
|
||||||
this.logger.debug(`Dial:_attemptCalls - declined: ${sd.callSid}`);
|
this.logger.debug(`Dial:_attemptCalls - declined: ${sd.callSid}`);
|
||||||
clearTimeout(this.timerRing);
|
|
||||||
this.dials.delete(sd.callSid);
|
this.dials.delete(sd.callSid);
|
||||||
sd.removeAllListeners();
|
|
||||||
if (this.dials.size === 0 && !this.sd) {
|
if (this.dials.size === 0 && !this.sd) {
|
||||||
this.logger.debug('Dial:_attemptCalls - all calls failed after decline, ending task');
|
this.logger.debug('Dial:_attemptCalls - all calls failed after decline, ending task');
|
||||||
this.kill(cs);
|
this.kill(cs);
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.on('reinvite', (req, res) => {
|
|
||||||
this._onReinvite(req, res);
|
|
||||||
})
|
|
||||||
.on('refer', (callInfo, req, res) => {
|
|
||||||
|
|
||||||
})
|
|
||||||
.once('adulting', () => {
|
|
||||||
/* child call just adulted and got its own session */
|
|
||||||
this.logger.info('Dial:on_adulting: detaching child call leg');
|
|
||||||
if (this.ep) {
|
|
||||||
this.logger.debug(`Dial:on_adulting: removing dtmf from ${this.ep.uuid}`);
|
|
||||||
this.ep.removeAllListeners('dtmf');
|
|
||||||
}
|
|
||||||
this.sd = null;
|
|
||||||
this.callSid = null;
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(err, 'Dial:_attemptCalls');
|
this.logger.error(err, 'Dial:_attemptCalls');
|
||||||
@@ -661,67 +344,29 @@ class TaskDial extends Task {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async _connectSingleDial(cs, sd) {
|
_connectSingleDial(cs, sd) {
|
||||||
if (!this.bridged && !this.canReleaseMedia) {
|
if (!this.bridged) {
|
||||||
this.logger.debug('Dial:_connectSingleDial bridging endpoints');
|
this.logger.debug('Dial:_connectSingleDial bridging endpoints');
|
||||||
if (this.epOther) {
|
this.epOther.api('uuid_break', this.epOther.uuid);
|
||||||
this.epOther.api('uuid_break', this.epOther.uuid);
|
this.epOther.bridge(sd.ep);
|
||||||
this.epOther.bridge(sd.ep);
|
|
||||||
}
|
|
||||||
this.bridged = true;
|
this.bridged = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ding! ding! ding! we have a winner
|
// ding! ding! ding! we have a winner
|
||||||
await this._selectSingleDial(cs, sd);
|
this._selectSingleDial(cs, sd);
|
||||||
this._killOutdials(); // NB: order is important
|
this._killOutdials(); // NB: order is important
|
||||||
}
|
}
|
||||||
|
|
||||||
async _onReinvite(req, res) {
|
|
||||||
try {
|
|
||||||
let isHandled = false;
|
|
||||||
if (this.cs.onHoldMusic) {
|
|
||||||
if (isOnhold(req.body) && !this.epOther && !this.ep) {
|
|
||||||
await this.cs.handleReinviteAfterMediaReleased(req, res);
|
|
||||||
// Onhold but media is already released
|
|
||||||
// reconnect A Leg and Response B leg
|
|
||||||
await this.reAnchorMedia(this.cs, this.sd);
|
|
||||||
this.isOutgoingLegHold = true;
|
|
||||||
isHandled = true;
|
|
||||||
this._onHoldHook();
|
|
||||||
} else if (!isOnhold(req.body) && this.epOther && this.ep && this.isOutgoingLegHold && this.canReleaseMedia) {
|
|
||||||
// Offhold, time to release media
|
|
||||||
const newSdp = await this.ep.modify(req.body);
|
|
||||||
await res.send(200, {body: newSdp});
|
|
||||||
await this._releaseMedia(this.cs, this.sd);
|
|
||||||
isHandled = true;
|
|
||||||
this.isOutgoingLegHold = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!isHandled) {
|
|
||||||
this.cs.handleReinviteAfterMediaReleased(req, res);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(err, 'Error in dial einvite from B leg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_onMaxCallDuration(cs) {
|
|
||||||
this.logger.info(`Dial:_onMaxCallDuration tearing down call as it has reached ${this.timeLimit}s`);
|
|
||||||
this.ep && this.ep.unbridge();
|
|
||||||
this.kill(cs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* We now have a call leg produced by the Dial action, so
|
* We now have a call leg produced by the Dial action, so
|
||||||
* - hangup any simrings in progress
|
* - hangup any simrings in progress
|
||||||
* - save the dialog and endpoint
|
* - save the dialog and endpoint
|
||||||
* - clock the start time of the call,
|
* - clock the start time of the call,
|
||||||
* - start a max call length timer (optionally)
|
* - start a max call length timer (optionally)
|
||||||
* - start answering machine detection (optionally)
|
|
||||||
* - launch any nested tasks
|
* - launch any nested tasks
|
||||||
* - and establish a handler to clean up if the called party hangs up
|
* - and establish a handler to clean up if the called party hangs up
|
||||||
*/
|
*/
|
||||||
async _selectSingleDial(cs, sd) {
|
_selectSingleDial(cs, sd) {
|
||||||
debug(`Dial:_selectSingleDial ep for outbound call: ${sd.ep.uuid}`);
|
debug(`Dial:_selectSingleDial ep for outbound call: ${sd.ep.uuid}`);
|
||||||
this.dials.delete(sd.callSid);
|
this.dials.delete(sd.callSid);
|
||||||
|
|
||||||
@@ -729,48 +374,33 @@ class TaskDial extends Task {
|
|||||||
this.callSid = sd.callSid;
|
this.callSid = sd.callSid;
|
||||||
if (this.earlyMedia) {
|
if (this.earlyMedia) {
|
||||||
debug('Dial:_selectSingleDial propagating answer supervision on A leg now that B is connected');
|
debug('Dial:_selectSingleDial propagating answer supervision on A leg now that B is connected');
|
||||||
await cs.propagateAnswer();
|
cs.propagateAnswer();
|
||||||
}
|
}
|
||||||
if (this.timeLimit) {
|
if (this.timeLimit) {
|
||||||
this.timerMaxCallDuration = setTimeout(this._onMaxCallDuration.bind(this, cs), this.timeLimit * 1000);
|
this.timerMaxCallDuration = setTimeout(() => {
|
||||||
|
this.logger.info(`Dial:_selectSingleDial tearing down call as it has reached ${this.timeLimit}s`);
|
||||||
|
this.ep.unbridge();
|
||||||
|
this.kill(cs);
|
||||||
|
}, this.timeLimit * 1000);
|
||||||
}
|
}
|
||||||
sessionTracker.add(this.callSid, cs);
|
sessionTracker.add(this.callSid, cs);
|
||||||
this.dlg.on('destroy', () => {
|
this.dlg.on('destroy', () => {
|
||||||
/* if our child is adulting, he's own his own now.. */
|
this.logger.debug('Dial:_selectSingleDial called party hungup, ending dial operation');
|
||||||
if (this.dlg) {
|
sessionTracker.remove(this.callSid);
|
||||||
this.logger.debug('Dial:_selectSingleDial called party hungup, ending dial operation');
|
if (this.timerMaxCallDuration) clearTimeout(this.timerMaxCallDuration);
|
||||||
sessionTracker.remove(this.callSid);
|
this.ep.unbridge();
|
||||||
if (this.timerMaxCallDuration) {
|
this.kill(cs);
|
||||||
clearTimeout(this.timerMaxCallDuration);
|
|
||||||
this.timerMaxCallDuration = null;
|
|
||||||
}
|
|
||||||
this.ep && this.ep.unbridge();
|
|
||||||
this.kill(cs);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Object.assign(this.results, {
|
Object.assign(this.results, {
|
||||||
dialCallStatus: CallStatus.Completed,
|
dialCallStatus: CallStatus.Completed,
|
||||||
dialSipStatus: 200,
|
|
||||||
dialCallSid: sd.callSid,
|
dialCallSid: sd.callSid,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.parentDtmfCollector) this._installDtmfDetection(cs, cs.dlg);
|
if (this.childDtmfCollector) this._installDtmfDetection(cs, this.ep, this.childDtmfCollector);
|
||||||
if (this.childDtmfCollector) this._installDtmfDetection(cs, this.dlg);
|
|
||||||
if (cs.sipRequestWithinDialogHook) this._initSipIndialogRequestListener(cs, this.dlg);
|
|
||||||
|
|
||||||
if (this.transcribeTask) this.transcribeTask.exec(cs, {ep: this.epOther, ep2:this.ep});
|
if (this.transcribeTask) this.transcribeTask.exec(cs, this.ep);
|
||||||
if (this.listenTask) this.listenTask.exec(cs, {ep: this.epOther});
|
if (this.listenTask) this.listenTask.exec(cs, this.ep);
|
||||||
if (this.startAmd) {
|
|
||||||
try {
|
|
||||||
this.startAmd(cs, this.ep, this, this.data.amd);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info({err}, 'Dial:_selectSingleDial - Error calling startAmd');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* if we can release the media back to the SBC, do so now */
|
|
||||||
if (this.canReleaseMedia) setTimeout(this._releaseMedia.bind(this, cs, sd), 200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_bridgeEarlyMedia(sd) {
|
_bridgeEarlyMedia(sd) {
|
||||||
@@ -782,111 +412,6 @@ class TaskDial extends Task {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Release the media from freeswitch
|
|
||||||
* @param {*} cs
|
|
||||||
* @param {*} sd
|
|
||||||
*/
|
|
||||||
async _releaseMedia(cs, sd) {
|
|
||||||
assert(cs.ep && sd.ep);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Wait until we got new SDP from B leg to ofter to A Leg
|
|
||||||
const aLegSdp = cs.ep.remote.sdp;
|
|
||||||
await sd.releaseMediaToSBC(aLegSdp, cs.ep.local.sdp);
|
|
||||||
const bLegSdp = sd.dlg.remote.sdp;
|
|
||||||
await cs.releaseMediaToSBC(bLegSdp);
|
|
||||||
this.epOther = null;
|
|
||||||
this.logger.info('Dial:_releaseMedia - successfully released media from freewitch');
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info({err}, 'Dial:_releaseMedia error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async reAnchorMedia(cs, sd) {
|
|
||||||
if (cs.ep && sd.ep) return;
|
|
||||||
|
|
||||||
this.logger.info('Dial:reAnchorMedia - re-anchoring media to freewitch');
|
|
||||||
await Promise.all([sd.reAnchorMedia(), cs.reAnchorMedia()]);
|
|
||||||
this.epOther = cs.ep;
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleReinviteAfterMediaReleased(req, res) {
|
|
||||||
let isHandled = false;
|
|
||||||
if (isOnhold(req.body) && !this.epOther && !this.ep) {
|
|
||||||
const sdp = await this.dlg.modify(req.body);
|
|
||||||
res.send(200, {body: sdp});
|
|
||||||
// Onhold but media is already released
|
|
||||||
await this.reAnchorMedia(this.cs, this.sd);
|
|
||||||
isHandled = true;
|
|
||||||
this.isIncomingLegHold = true;
|
|
||||||
this._onHoldHook();
|
|
||||||
} else if (!isOnhold(req.body) && this.epOther && this.ep && this.isIncomingLegHold && this.canReleaseMedia) {
|
|
||||||
// Offhold, time to release media
|
|
||||||
const newSdp = await this.epOther.modify(req.body);
|
|
||||||
await res.send(200, {body: newSdp});
|
|
||||||
await this._releaseMedia(this.cs, this.sd);
|
|
||||||
isHandled = true;
|
|
||||||
this.isIncomingLegHold = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isHandled) {
|
|
||||||
const sdp = await this.dlg.modify(req.body);
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async _onHoldHook(allowed = [TaskName.Play, TaskName.Say, TaskName.Pause]) {
|
|
||||||
if (this.data.onHoldHook) {
|
|
||||||
// send silence for keep Voice quality
|
|
||||||
await this.epOther.play('silence_stream://500');
|
|
||||||
let allowedTasks;
|
|
||||||
do {
|
|
||||||
try {
|
|
||||||
const b3 = this.getTracingPropagation();
|
|
||||||
const httpHeaders = b3 && {b3};
|
|
||||||
const json = await this.cs.application.requestor.
|
|
||||||
request('verb:hook', this.data.onHoldHook, this.cs.callInfo.toJSON(), httpHeaders);
|
|
||||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
|
||||||
allowedTasks = tasks.filter((t) => allowed.includes(t.name));
|
|
||||||
if (tasks.length !== allowedTasks.length) {
|
|
||||||
this.logger.debug({tasks, allowedTasks}, 'unsupported task');
|
|
||||||
throw new Error(`unsupported verb in enqueue waitHook: only ${JSON.stringify(allowed)}`);
|
|
||||||
}
|
|
||||||
this.logger.debug(`DialTask:_onHoldHook: executing ${tasks.length} tasks`);
|
|
||||||
if (tasks.length) {
|
|
||||||
this._playSession = new ConfirmCallSession({
|
|
||||||
logger: this.logger,
|
|
||||||
application: this.cs.application,
|
|
||||||
dlg: this.isIncomingLegHold ? this.dlg : this.cs.dlg,
|
|
||||||
ep: this.isIncomingLegHold ? this.ep : this.cs.ep,
|
|
||||||
callInfo: this.cs.callInfo,
|
|
||||||
accountInfo: this.cs.accountInfo,
|
|
||||||
tasks,
|
|
||||||
rootSpan: this.cs.rootSpan
|
|
||||||
});
|
|
||||||
await this._playSession.exec();
|
|
||||||
this._playSession = null;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.info(error, 'DialTask:_onHoldHook: failed retrieving waitHook');
|
|
||||||
this._playSession = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} while (allowedTasks && allowedTasks.length > 0 && !this.killed && this.isOnHold);
|
|
||||||
this.logger.info('Finish onHoldHook');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = TaskDial;
|
module.exports = TaskDial;
|
||||||
|
|||||||
@@ -3,28 +3,15 @@ const {TaskName, TaskPreconditions} = require('../../utils/constants');
|
|||||||
const Intent = require('./intent');
|
const Intent = require('./intent');
|
||||||
const DigitBuffer = require('./digit-buffer');
|
const DigitBuffer = require('./digit-buffer');
|
||||||
const Transcription = require('./transcription');
|
const Transcription = require('./transcription');
|
||||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
const normalizeJambones = require('../../utils/normalize-jambones');
|
||||||
|
|
||||||
class Dialogflow extends Task {
|
class Dialogflow extends Task {
|
||||||
constructor(logger, opts) {
|
constructor(logger, opts) {
|
||||||
super(logger, opts);
|
super(logger, opts);
|
||||||
this.preconditions = TaskPreconditions.Endpoint;
|
this.preconditions = TaskPreconditions.Endpoint;
|
||||||
|
|
||||||
this.credentials = this.data.credentials;
|
this.credentials = this.data.credentials;
|
||||||
|
this.project = this.data.project;
|
||||||
/* set project id with environment and region (optionally) */
|
|
||||||
if (this.data.environment && this.data.region) {
|
|
||||||
this.project = `${this.data.project}:${this.data.environment}:${this.data.region}`;
|
|
||||||
}
|
|
||||||
else if (this.data.environment) {
|
|
||||||
this.project = `${this.data.project}:${this.data.environment}`;
|
|
||||||
}
|
|
||||||
else if (this.data.region) {
|
|
||||||
this.project = `${this.data.project}::${this.data.region}`;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.project = this.data.project;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.lang = this.data.lang || 'en-US';
|
this.lang = this.data.lang || 'en-US';
|
||||||
this.welcomeEvent = this.data.welcomeEvent || '';
|
this.welcomeEvent = this.data.welcomeEvent || '';
|
||||||
if (this.welcomeEvent.length && this.data.welcomeEventParams && typeof this.data.welcomeEventParams === 'object') {
|
if (this.welcomeEvent.length && this.data.welcomeEventParams && typeof this.data.welcomeEventParams === 'object') {
|
||||||
@@ -54,24 +41,11 @@ class Dialogflow extends Task {
|
|||||||
}
|
}
|
||||||
if (this.data.actionHook) this.actionHook = this.data.actionHook;
|
if (this.data.actionHook) this.actionHook = this.data.actionHook;
|
||||||
if (this.data.thinkingMusic) this.thinkingMusic = this.data.thinkingMusic;
|
if (this.data.thinkingMusic) this.thinkingMusic = this.data.thinkingMusic;
|
||||||
if (this.data.tts) {
|
|
||||||
this.vendor = this.data.tts.vendor || 'default';
|
|
||||||
this.language = this.data.tts.language || 'default';
|
|
||||||
this.voice = this.data.tts.voice || 'default';
|
|
||||||
this.speechSynthesisLabel = this.data.tts.label;
|
|
||||||
|
|
||||||
// fallback tts
|
|
||||||
this.fallbackVendor = this.data.tts.fallbackVendor || 'default';
|
|
||||||
this.fallbackLanguage = this.data.tts.fallbackLanguage || 'default';
|
|
||||||
this.fallbackVoice = this.data.tts.fallbackLanguage || 'default';
|
|
||||||
this.fallbackLabel = this.data.tts.fallbackLabel;
|
|
||||||
}
|
|
||||||
this.bargein = this.data.bargein;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() { return TaskName.Dialogflow; }
|
get name() { return TaskName.Dialogflow; }
|
||||||
|
|
||||||
async exec(cs, {ep}) {
|
async exec(cs, ep) {
|
||||||
await super.exec(cs);
|
await super.exec(cs);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -80,12 +54,13 @@ class Dialogflow extends Task {
|
|||||||
this.logger.debug(`starting dialogflow bot ${this.project}`);
|
this.logger.debug(`starting dialogflow bot ${this.project}`);
|
||||||
|
|
||||||
// kick it off
|
// kick it off
|
||||||
const baseArgs = `${this.ep.uuid} ${this.project} ${this.lang} ${this.welcomeEvent}`;
|
|
||||||
if (this.welcomeEventParams) {
|
if (this.welcomeEventParams) {
|
||||||
this.ep.api('dialogflow_start', `${baseArgs} '${JSON.stringify(this.welcomeEventParams)}'`);
|
this.ep.api('dialogflow_start',
|
||||||
|
`${this.ep.uuid} ${this.project} ${this.lang} ${this.welcomeEvent} '${JSON.stringify(this.welcomeEventParams)}'`);
|
||||||
}
|
}
|
||||||
else if (this.welcomeEvent.length) {
|
else if (this.welcomeEvent.length) {
|
||||||
this.ep.api('dialogflow_start', baseArgs);
|
this.ep.api('dialogflow_start',
|
||||||
|
`${this.ep.uuid} ${this.project} ${this.lang} ${this.welcomeEvent}`);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
this.ep.api('dialogflow_start', `${this.ep.uuid} ${this.project} ${this.lang}`);
|
this.ep.api('dialogflow_start', `${this.ep.uuid} ${this.project} ${this.lang}`);
|
||||||
@@ -108,9 +83,7 @@ class Dialogflow extends Task {
|
|||||||
this.ep.removeCustomEventListener('dialogflow::end_of_utterance');
|
this.ep.removeCustomEventListener('dialogflow::end_of_utterance');
|
||||||
this.ep.removeCustomEventListener('dialogflow::error');
|
this.ep.removeCustomEventListener('dialogflow::error');
|
||||||
|
|
||||||
this._clearNoinputTimer();
|
this.performAction({dialogflowResult: 'caller hungup'})
|
||||||
|
|
||||||
if (!this.reportedFinalAction) this.performAction({dialogflowResult: 'caller hungup'})
|
|
||||||
.catch((err) => this.logger.error({err}, 'dialogflow - error w/ action webook'));
|
.catch((err) => this.logger.error({err}, 'dialogflow - error w/ action webook'));
|
||||||
|
|
||||||
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||||
@@ -121,20 +94,6 @@ class Dialogflow extends Task {
|
|||||||
async init(cs, ep) {
|
async init(cs, ep) {
|
||||||
this.ep = ep;
|
this.ep = ep;
|
||||||
try {
|
try {
|
||||||
if (this.vendor === 'default') {
|
|
||||||
this.vendor = cs.speechSynthesisVendor;
|
|
||||||
this.language = cs.speechSynthesisLanguage;
|
|
||||||
this.voice = cs.speechSynthesisVoice;
|
|
||||||
this.speechSynthesisLabel = cs.speechSynthesisLabel;
|
|
||||||
}
|
|
||||||
if (this.fallbackVendor === 'default') {
|
|
||||||
this.fallbackVendor = cs.fallbackSpeechSynthesisVendor;
|
|
||||||
this.fallbackLanguage = cs.fallbackSpeechSynthesisLanguage;
|
|
||||||
this.fallbackVoice = cs.fallbackSpeechSynthesisVoice;
|
|
||||||
this.fallbackLabel = cs.fallbackSpeechSynthesisLabel;
|
|
||||||
}
|
|
||||||
this.ttsCredentials = cs.getSpeechCredentials(this.vendor, 'tts', this.speechSynthesisLabel);
|
|
||||||
|
|
||||||
this.ep.addCustomEventListener('dialogflow::intent', this._onIntent.bind(this, ep, cs));
|
this.ep.addCustomEventListener('dialogflow::intent', this._onIntent.bind(this, ep, cs));
|
||||||
this.ep.addCustomEventListener('dialogflow::transcription', this._onTranscription.bind(this, ep, cs));
|
this.ep.addCustomEventListener('dialogflow::transcription', this._onTranscription.bind(this, ep, cs));
|
||||||
this.ep.addCustomEventListener('dialogflow::audio_provided', this._onAudioProvided.bind(this, ep, cs));
|
this.ep.addCustomEventListener('dialogflow::audio_provided', this._onAudioProvided.bind(this, ep, cs));
|
||||||
@@ -158,7 +117,7 @@ class Dialogflow extends Task {
|
|||||||
* @param {*} ep - media server endpoint
|
* @param {*} ep - media server endpoint
|
||||||
* @param {*} evt - event data
|
* @param {*} evt - event data
|
||||||
*/
|
*/
|
||||||
async _onIntent(ep, cs, evt) {
|
_onIntent(ep, cs, evt) {
|
||||||
const intent = new Intent(this.logger, evt);
|
const intent = new Intent(this.logger, evt);
|
||||||
|
|
||||||
if (intent.isEmpty) {
|
if (intent.isEmpty) {
|
||||||
@@ -184,7 +143,6 @@ class Dialogflow extends Task {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
this.logger.info('got empty intent');
|
this.logger.info('got empty intent');
|
||||||
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang}`);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -220,104 +178,6 @@ class Dialogflow extends Task {
|
|||||||
this.digitBuffer = new DigitBuffer(this.logger, opts);
|
this.digitBuffer = new DigitBuffer(this.logger, opts);
|
||||||
this.digitBuffer.once('fulfilled', this._onDtmfEntryComplete.bind(this, ep));
|
this.digitBuffer.once('fulfilled', this._onDtmfEntryComplete.bind(this, ep));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* if we are using tts and a message was provided, play it out */
|
|
||||||
if (this.vendor && intent.fulfillmentText && intent.fulfillmentText.length > 0) {
|
|
||||||
const {srf} = cs;
|
|
||||||
const {stats} = srf.locals;
|
|
||||||
const {synthAudio} = srf.locals.dbHelpers;
|
|
||||||
this.waitingForPlayStart = false;
|
|
||||||
|
|
||||||
// start a new intent, (we want to continue to listen during the audio playback)
|
|
||||||
// _unless_ we are transferring or ending the session
|
|
||||||
if (!this.hangupAfterPlayDone) {
|
|
||||||
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const {filePath} = await this._fallbackSynthAudio(cs, intent, stats, synthAudio);
|
|
||||||
if (filePath) cs.trackTmpFile(filePath);
|
|
||||||
|
|
||||||
if (this.playInProgress) {
|
|
||||||
await ep.api('uuid_break', ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
|
||||||
}
|
|
||||||
this.playInProgress = true;
|
|
||||||
this.curentAudioFile = filePath;
|
|
||||||
|
|
||||||
this.logger.debug(`starting to play tts ${filePath}`);
|
|
||||||
|
|
||||||
if (this.events.includes('start-play')) {
|
|
||||||
this._performHook(cs, this.eventHook, {event: 'start-play', data: {path: filePath}});
|
|
||||||
}
|
|
||||||
await ep.play(filePath);
|
|
||||||
if (this.events.includes('stop-play')) {
|
|
||||||
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: filePath}});
|
|
||||||
}
|
|
||||||
this.logger.debug(`finished ${filePath}`);
|
|
||||||
|
|
||||||
if (this.curentAudioFile === filePath) {
|
|
||||||
this.playInProgress = false;
|
|
||||||
if (this.queuedTasks) {
|
|
||||||
this.logger.debug('finished playing audio and we have queued tasks');
|
|
||||||
this._redirect(cs, this.queuedTasks);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.greetingPlayed = true;
|
|
||||||
|
|
||||||
if (this.hangupAfterPlayDone) {
|
|
||||||
this.logger.info('hanging up since intent was marked end interaction and we completed final prompt');
|
|
||||||
this.performAction({dialogflowResult: 'completed'});
|
|
||||||
this.notifyTaskDone();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// every time we finish playing a prompt, start the no-input timer
|
|
||||||
this._startNoinputTimer(ep, cs);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error({err}, 'Dialogflow:_onIntent - error playing tts');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _fallbackSynthAudio(cs, intent, stats, synthAudio) {
|
|
||||||
try {
|
|
||||||
const obj = {
|
|
||||||
account_sid: cs.accountSid,
|
|
||||||
text: intent.fulfillmentText,
|
|
||||||
vendor: this.vendor,
|
|
||||||
language: this.language,
|
|
||||||
voice: this.voice,
|
|
||||||
salt: cs.callSid,
|
|
||||||
credentials: this.ttsCredentials
|
|
||||||
};
|
|
||||||
this.logger.debug({obj}, 'Dialogflow:_onIntent - playing message via tts');
|
|
||||||
|
|
||||||
return await synthAudio(stats, obj);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.info({error}, 'Failed to synthesize audio from primary vendor');
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (this.fallbackVendor) {
|
|
||||||
const credentials = cs.getSpeechCredentials(this.fallbackVendor, 'tts', this.fallbackLabel);
|
|
||||||
const obj = {
|
|
||||||
account_sid: cs.accountSid,
|
|
||||||
text: intent.fulfillmentText,
|
|
||||||
vendor: this.fallbackVendor,
|
|
||||||
language: this.fallbackLanguage,
|
|
||||||
voice: this.fallbackVoice,
|
|
||||||
salt: cs.callSid,
|
|
||||||
credentials
|
|
||||||
};
|
|
||||||
this.logger.debug({obj}, 'Dialogflow:_onIntent - playing message via fallback tts');
|
|
||||||
return await synthAudio(stats, obj);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info({err}, 'Failed to synthesize audio from falllback vendor');
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -328,7 +188,7 @@ class Dialogflow extends Task {
|
|||||||
* @param {*} ep - media server endpoint
|
* @param {*} ep - media server endpoint
|
||||||
* @param {*} evt - event data
|
* @param {*} evt - event data
|
||||||
*/
|
*/
|
||||||
async _onTranscription(ep, cs, evt) {
|
_onTranscription(ep, cs, evt) {
|
||||||
const transcription = new Transcription(this.logger, evt);
|
const transcription = new Transcription(this.logger, evt);
|
||||||
|
|
||||||
if (this.events.includes('transcription') && transcription.isFinal) {
|
if (this.events.includes('transcription') && transcription.isFinal) {
|
||||||
@@ -339,16 +199,9 @@ class Dialogflow extends Task {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// if a final transcription, start a typing sound
|
// if a final transcription, start a typing sound
|
||||||
if (this.thinkingMusic && !transcription.isEmpty && transcription.isFinal &&
|
if (this.thinkingSound > 0 && !transcription.isEmpty && transcription.isFinal &&
|
||||||
transcription.confidence > 0.8) {
|
transcription.confidence > 0.8) {
|
||||||
ep.play(this.data.thinkingMusic).catch((err) => this.logger.info(err, 'Error playing typing sound'));
|
ep.play(this.data.thinkingSound).catch((err) => this.logger.info(err, 'Error playing typing sound'));
|
||||||
}
|
|
||||||
|
|
||||||
// interrupt playback on speaking if bargein = true
|
|
||||||
if (this.bargein && this.playInProgress) {
|
|
||||||
this.logger.debug('terminating playback due to speech bargein');
|
|
||||||
this.playInProgress = false;
|
|
||||||
await ep.api('uuid_break', ep.uuid);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,9 +231,6 @@ class Dialogflow extends Task {
|
|||||||
* @param {*} evt - event data
|
* @param {*} evt - event data
|
||||||
*/
|
*/
|
||||||
async _onAudioProvided(ep, cs, evt) {
|
async _onAudioProvided(ep, cs, evt) {
|
||||||
|
|
||||||
if (this.vendor) return;
|
|
||||||
|
|
||||||
this.waitingForPlayStart = false;
|
this.waitingForPlayStart = false;
|
||||||
|
|
||||||
// kill filler audio
|
// kill filler audio
|
||||||
@@ -403,16 +253,10 @@ class Dialogflow extends Task {
|
|||||||
if (this.events.includes('stop-play')) {
|
if (this.events.includes('stop-play')) {
|
||||||
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: evt.path}});
|
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: evt.path}});
|
||||||
}
|
}
|
||||||
this.logger.info(`finished ${evt.path}, queued tasks: ${(this.queuedTasks || []).length}`);
|
this.logger.info(`finished ${evt.path}`);
|
||||||
|
|
||||||
if (this.curentAudioFile === evt.path) {
|
if (this.curentAudioFile === evt.path) {
|
||||||
this.playInProgress = false;
|
this.playInProgress = false;
|
||||||
if (this.queuedTasks) {
|
|
||||||
this.logger.debug('finished playing audio and we have queued tasks');
|
|
||||||
this._redirect(cs, this.queuedTasks);
|
|
||||||
this.queuedTasks.length = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
if (!this.inbound && !this.greetingPlayed) {
|
if (!this.inbound && !this.greetingPlayed) {
|
||||||
@@ -449,8 +293,8 @@ class Dialogflow extends Task {
|
|||||||
this.dtmfEntry = dtmfEntry;
|
this.dtmfEntry = dtmfEntry;
|
||||||
this.digitBuffer = null;
|
this.digitBuffer = null;
|
||||||
// if a final transcription, start a typing sound
|
// if a final transcription, start a typing sound
|
||||||
if (this.thinkingMusic) {
|
if (this.thinkingSound > 0) {
|
||||||
ep.play(this.thinkingMusic).catch((err) => this.logger.info(err, 'Error playing typing sound'));
|
ep.play(this.thinkingSound).catch((err) => this.logger.info(err, 'Error playing typing sound'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// kill the current dialogflow, which will result in us getting an immediate intent
|
// kill the current dialogflow, which will result in us getting an immediate intent
|
||||||
@@ -496,33 +340,19 @@ class Dialogflow extends Task {
|
|||||||
this.noinputTimer = setTimeout(this._onNoInput.bind(this, ep, cs), this.noInputTimeout);
|
this.noinputTimer = setTimeout(this._onNoInput.bind(this, ep, cs), this.noInputTimeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _performHook(cs, hook, results = {}) {
|
async _performHook(cs, hook, results) {
|
||||||
const b3 = this.getTracingPropagation();
|
const json = await this.cs.requestor.request(hook, results);
|
||||||
const httpHeaders = b3 && {b3};
|
|
||||||
const json = await this.cs.requestor.request('verb:hook', hook,
|
|
||||||
{...results, ...cs.callInfo.toJSON()}, httpHeaders);
|
|
||||||
if (json && Array.isArray(json)) {
|
if (json && Array.isArray(json)) {
|
||||||
const makeTask = require('../make_task');
|
const makeTask = require('../make_task');
|
||||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||||
if (tasks && tasks.length > 0) {
|
if (tasks && tasks.length > 0) {
|
||||||
if (this.playInProgress) {
|
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
|
||||||
this.queuedTasks = tasks;
|
this.performAction({dialogflowResult: 'redirect'}, false);
|
||||||
this.logger.info({tasks: tasks},
|
cs.replaceApplication(tasks);
|
||||||
`${this.name} replacing application with ${tasks.length} tasks after play completes`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._redirect(cs, tasks);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_redirect(cs, tasks) {
|
|
||||||
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
|
|
||||||
this.performAction({dialogflowResult: 'redirect'}, false);
|
|
||||||
this.reportedFinalAction = true;
|
|
||||||
cs.replaceApplication(tasks);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Dialogflow;
|
module.exports = Dialogflow;
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
const Task = require('./task');
|
|
||||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
|
||||||
|
|
||||||
class TaskDtmf extends Task {
|
|
||||||
constructor(logger, opts) {
|
|
||||||
super(logger, opts);
|
|
||||||
this.preconditions = TaskPreconditions.Endpoint;
|
|
||||||
|
|
||||||
this.dtmf = this.data.dtmf;
|
|
||||||
this.duration = this.data.duration || 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() { return TaskName.Dtmf; }
|
|
||||||
|
|
||||||
async exec(cs, {ep}) {
|
|
||||||
await super.exec(cs);
|
|
||||||
this.ep = ep;
|
|
||||||
try {
|
|
||||||
this.logger.info({data: this.data}, `sending dtmf ${this.dtmf}`);
|
|
||||||
await this.ep.execute('send_dtmf', `${this.dtmf}@${this.duration}`);
|
|
||||||
this.timer = setTimeout(this.notifyTaskDone.bind(this), this.dtmf.length * (this.duration + 250) + 750);
|
|
||||||
await this.awaitTaskDone();
|
|
||||||
this.logger.info({data: this.data}, `done sending dtmf ${this.dtmf}`);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info(err, `TaskDtmf:exec - error playing ${this.dtmf}`);
|
|
||||||
}
|
|
||||||
this.emit('playDone');
|
|
||||||
}
|
|
||||||
|
|
||||||
async kill(cs) {
|
|
||||||
super.kill(cs);
|
|
||||||
if (this.ep.connected && !this.playComplete) {
|
|
||||||
this.logger.debug('TaskDtmf:kill - killing audio');
|
|
||||||
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
|
||||||
}
|
|
||||||
clearTimeout(this.timer);
|
|
||||||
this.notifyTaskDone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = TaskDtmf;
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
const Task = require('./task');
|
const Task = require('./task');
|
||||||
const Emitter = require('events');
|
const Emitter = require('events');
|
||||||
const ConfirmCallSession = require('../session/confirm-call-session');
|
const ConfirmCallSession = require('../session/confirm-call-session');
|
||||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
const normalizeJambones = require('../utils/normalize-jambones');
|
||||||
const makeTask = require('./make_task');
|
const makeTask = require('./make_task');
|
||||||
const {TaskName, TaskPreconditions, QueueResults, KillReason} = require('../utils/constants');
|
const {TaskName, TaskPreconditions, QueueResults} = require('../utils/constants');
|
||||||
const bent = require('bent');
|
const bent = require('bent');
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
|
|
||||||
@@ -18,7 +18,6 @@ class TaskEnqueue extends Task {
|
|||||||
this.preconditions = TaskPreconditions.Endpoint;
|
this.preconditions = TaskPreconditions.Endpoint;
|
||||||
|
|
||||||
this.queueName = this.data.name;
|
this.queueName = this.data.name;
|
||||||
this.priority = this.data.priority;
|
|
||||||
this.waitHook = this.data.waitHook;
|
this.waitHook = this.data.waitHook;
|
||||||
|
|
||||||
this.emitter = new Emitter();
|
this.emitter = new Emitter();
|
||||||
@@ -38,7 +37,7 @@ class TaskEnqueue extends Task {
|
|||||||
|
|
||||||
get name() { return TaskName.Enqueue; }
|
get name() { return TaskName.Enqueue; }
|
||||||
|
|
||||||
async exec(cs, {ep}) {
|
async exec(cs, ep) {
|
||||||
await super.exec(cs);
|
await super.exec(cs);
|
||||||
const dlg = cs.dlg;
|
const dlg = cs.dlg;
|
||||||
this.queueName = `queue:${cs.accountSid}:${this.queueName}`;
|
this.queueName = `queue:${cs.accountSid}:${this.queueName}`;
|
||||||
@@ -62,48 +61,26 @@ class TaskEnqueue extends Task {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async kill(cs, reason) {
|
async kill(cs) {
|
||||||
super.kill(cs);
|
super.kill(cs);
|
||||||
this.killReason = reason || KillReason.Hangup;
|
this.logger.info(`TaskEnqueue:kill ${this.queueName}`);
|
||||||
this.logger.info(`TaskEnqueue:kill ${this.queueName} with reason ${this.killReason}`);
|
this.emitter.emit('kill');
|
||||||
this.emitter.emit('kill', reason || KillReason.Hangup);
|
|
||||||
this.notifyTaskDone();
|
this.notifyTaskDone();
|
||||||
}
|
}
|
||||||
|
|
||||||
async _addToQueue(cs, dlg) {
|
async _addToQueue(cs, dlg) {
|
||||||
const {addToSortedSet, sortedSetLength} = cs.srf.locals.dbHelpers;
|
const {pushBack} = cs.srf.locals.dbHelpers;
|
||||||
const url = getUrl(cs);
|
const url = getUrl(cs);
|
||||||
this.waitStartTime = Date.now();
|
this.waitStartTime = Date.now();
|
||||||
this.logger.debug({queue: this.queueName, url}, 'pushing url onto queue');
|
this.logger.debug({queue: this.queueName, url}, 'pushing url onto queue');
|
||||||
if (this.priority < 0) {
|
const members = await pushBack(this.queueName, url);
|
||||||
this.logger.warn(`priority ${this.priority} is invalid, need to be non-negative integer,
|
this.logger.info(`TaskEnqueue:_addToQueue: added to queue, length now ${members}`);
|
||||||
999 will be used for priority`);
|
|
||||||
}
|
|
||||||
let members = await addToSortedSet(this.queueName, url, this.priority);
|
|
||||||
if (members === 1) {
|
|
||||||
this.logger.info('TaskEnqueue:_addToQueue: added to queue');
|
|
||||||
} else {
|
|
||||||
this.logger.info('TaskEnqueue:_addToQueue: failed to add to queue');
|
|
||||||
}
|
|
||||||
members = await sortedSetLength(this.queueName);
|
|
||||||
|
|
||||||
this.notifyUrl = url;
|
this.notifyUrl = url;
|
||||||
|
|
||||||
/* invoke account-level webhook for queue event notifications */
|
|
||||||
try {
|
|
||||||
cs.performQueueWebhook({
|
|
||||||
event: 'join',
|
|
||||||
queue: this.data.name,
|
|
||||||
length: members,
|
|
||||||
joinTime: this.waitStartTime
|
|
||||||
});
|
|
||||||
} catch (err) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async _removeFromQueue(cs) {
|
async _removeFromQueue(cs, dlg) {
|
||||||
const {retrieveByPatternSortedSet, sortedSetLength} = cs.srf.locals.dbHelpers;
|
const {removeFromList} = cs.srf.locals.dbHelpers;
|
||||||
await retrieveByPatternSortedSet(this.queueName, `*${getUrl(cs)}`);
|
return await removeFromList(this.queueName, getUrl(cs));
|
||||||
return await sortedSetLength(this.queueName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async performAction() {
|
async performAction() {
|
||||||
@@ -112,7 +89,7 @@ class TaskEnqueue extends Task {
|
|||||||
queueTime: getElapsedTime(this.waitStartTime),
|
queueTime: getElapsedTime(this.waitStartTime),
|
||||||
queueResult: this.state
|
queueResult: this.state
|
||||||
};
|
};
|
||||||
await super.performAction(params, this.killReason !== KillReason.Replaced);
|
await super.performAction(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -127,28 +104,13 @@ class TaskEnqueue extends Task {
|
|||||||
this.bridgeDetails = opts;
|
this.bridgeDetails = opts;
|
||||||
this.logger.info({bridgeDetails: this.bridgeDetails}, `time to dequeue from ${this.queueName}`);
|
this.logger.info({bridgeDetails: this.bridgeDetails}, `time to dequeue from ${this.queueName}`);
|
||||||
if (this._playSession) {
|
if (this._playSession) {
|
||||||
this._leave = false;
|
|
||||||
this._playSession.kill();
|
this._playSession.kill();
|
||||||
this._playSession = null;
|
this._playSession = null;
|
||||||
}
|
}
|
||||||
resolve(this._doBridge(cs, dlg, ep));
|
resolve(this._doBridge(cs, dlg, ep));
|
||||||
})
|
})
|
||||||
.once('kill', async() => {
|
.once('kill', () => {
|
||||||
|
this._removeFromQueue(cs);
|
||||||
/* invoke account-level webhook for queue event notifications */
|
|
||||||
if (!this.dequeued) {
|
|
||||||
try {
|
|
||||||
const members = await this._removeFromQueue(cs);
|
|
||||||
cs.performQueueWebhook({
|
|
||||||
event: 'leave',
|
|
||||||
queue: this.data.name,
|
|
||||||
length: members,
|
|
||||||
leaveReason: 'abandoned',
|
|
||||||
leaveTime: Date.now()
|
|
||||||
});
|
|
||||||
} catch (err) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._playSession) {
|
if (this._playSession) {
|
||||||
this.logger.debug('killing waitUrl');
|
this.logger.debug('killing waitUrl');
|
||||||
this._playSession.kill();
|
this._playSession.kill();
|
||||||
@@ -247,16 +209,14 @@ class TaskEnqueue extends Task {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// resolve when either side hangs up
|
// resolve when either side hangs up
|
||||||
this.state = QueueResults.Bridged;
|
|
||||||
this.emitter
|
this.emitter
|
||||||
.on('hangup', () => {
|
.on('hangup', () => {
|
||||||
this.logger.info('TaskEnqueue:_bridgeLocal ending with hangup from dequeue party');
|
this.logger.info('TaskEnqueue:_bridgeLocal ending with hangup from dequeue party');
|
||||||
ep.unbridge().catch((err) => {});
|
ep.unbridge().catch((err) => {});
|
||||||
resolve();
|
resolve();
|
||||||
})
|
})
|
||||||
.on('kill', (reason) => {
|
.on('kill', () => {
|
||||||
this.killReason = reason;
|
this.logger.info('TaskEnqueue:_bridgeLocal ending with hangup from enqeue party');
|
||||||
this.logger.info(`TaskEnqueue:_bridgeLocal ending with ${this.killReason}`);
|
|
||||||
ep.unbridge().catch((err) => {});
|
ep.unbridge().catch((err) => {});
|
||||||
|
|
||||||
// notify partner that we dropped
|
// notify partner that we dropped
|
||||||
@@ -282,26 +242,12 @@ class TaskEnqueue extends Task {
|
|||||||
* @param {string} opts.epUuid uuid of the endpoint we need to bridge to
|
* @param {string} opts.epUuid uuid of the endpoint we need to bridge to
|
||||||
* @param {string} opts.dequeueSipAddress ip:port of the feature server hosting the other call
|
* @param {string} opts.dequeueSipAddress ip:port of the feature server hosting the other call
|
||||||
*/
|
*/
|
||||||
async notifyQueueEvent(cs, opts) {
|
notifyQueueEvent(cs, opts) {
|
||||||
if (opts.event === 'dequeue') {
|
if (opts.event === 'dequeue') {
|
||||||
if (this.bridgeNow) return;
|
if (this.bridgeNow) return;
|
||||||
this.logger.info({opts}, `TaskEnqueue:notifyDequeueEvent: leaving ${this.queueName} because someone wants me`);
|
this.logger.info({opts}, `TaskEnqueue:notifyDequeueEvent: leaving ${this.queueName} because someone wants me`);
|
||||||
assert(opts.dequeueSipAddress && opts.epUuid && opts.notifyUrl);
|
assert(opts.dequeueSipAddress && opts.epUuid && opts.notifyUrl);
|
||||||
this.emitter.emit('dequeue', opts);
|
this.emitter.emit('dequeue', opts);
|
||||||
|
|
||||||
try {
|
|
||||||
const {sortedSetLength} = cs.srf.locals.dbHelpers;
|
|
||||||
const members = await sortedSetLength(this.queueName);
|
|
||||||
this.dequeued = true;
|
|
||||||
cs.performQueueWebhook({
|
|
||||||
event: 'leave',
|
|
||||||
queue: this.data.name,
|
|
||||||
length: Math.max(members, 0),
|
|
||||||
leaveReason: 'dequeued',
|
|
||||||
leaveTime: Date.now(),
|
|
||||||
dequeuer: opts.dequeuer
|
|
||||||
});
|
|
||||||
} catch (err) {}
|
|
||||||
}
|
}
|
||||||
else if (opts.event === 'hangup') {
|
else if (opts.event === 'hangup') {
|
||||||
this.emitter.emit('hangup');
|
this.emitter.emit('hangup');
|
||||||
@@ -311,11 +257,8 @@ class TaskEnqueue extends Task {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _playHook(cs, dlg, hook,
|
async _playHook(cs, dlg, hook, allowed = [TaskName.Play, TaskName.Say, TaskName.Pause, TaskName.Leave]) {
|
||||||
allowed = [TaskName.Play, TaskName.Say, TaskName.Pause, TaskName.Leave, TaskName.Tag]) {
|
const {lengthOfList, getListPosition} = cs.srf.locals.dbHelpers;
|
||||||
const {sortedSetLength, sortedSetPositionByPattern} = cs.srf.locals.dbHelpers;
|
|
||||||
const b3 = this.getTracingPropagation();
|
|
||||||
const httpHeaders = b3 && {b3};
|
|
||||||
|
|
||||||
assert(!this._playSession);
|
assert(!this._playSession);
|
||||||
if (this.killed) return [];
|
if (this.killed) return [];
|
||||||
@@ -325,59 +268,52 @@ class TaskEnqueue extends Task {
|
|||||||
queueTime: getElapsedTime(this.waitStartTime)
|
queueTime: getElapsedTime(this.waitStartTime)
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const queueSize = await sortedSetLength(this.queueName);
|
const queueSize = await lengthOfList(this.queueName);
|
||||||
const queuePosition = await sortedSetPositionByPattern(this.queueName, `*${this.notifyUrl}`);
|
const queuePosition = await getListPosition(this.queueName, this.notifyUrl);
|
||||||
Object.assign(params, {
|
Object.assign(params, {queueSize, queuePosition});
|
||||||
queueSize,
|
|
||||||
queuePosition: queuePosition.length ? queuePosition[0] : 0,
|
|
||||||
callSid: this.cs.callSid,
|
|
||||||
callId: this.cs.callId,
|
|
||||||
customerData: this.cs.callInfo.customerData
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error({err}, `TaskEnqueue:_playHook error retrieving list info for queue ${this.queueName}`);
|
this.logger.error({err}, `TaskEnqueue:_playHook error retrieving list info for queue ${this.queueName}`);
|
||||||
}
|
}
|
||||||
const json = await cs.application.requestor.request('verb:hook', hook, params, httpHeaders);
|
const json = await cs.application.requestor.request(hook, params);
|
||||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
|
||||||
|
|
||||||
const allowedTasks = tasks.filter((t) => allowed.includes(t.name));
|
const allowedTasks = json.filter((task) => allowed.includes(task.verb));
|
||||||
if (tasks.length !== allowedTasks.length) {
|
if (json.length !== allowedTasks.length) {
|
||||||
this.logger.debug({tasks, allowedTasks}, 'unsupported task');
|
this.logger.debug({json, allowedTasks}, 'unsupported task');
|
||||||
throw new Error(`unsupported verb in enqueue waitHook: only ${JSON.stringify(allowed)}`);
|
throw new Error(`unsupported verb in dial enqueue waitHook: only ${JSON.stringify(allowed)}`);
|
||||||
}
|
}
|
||||||
this.logger.debug(`TaskEnqueue:_playHook: executing ${tasks.length} tasks`);
|
this.logger.debug(`TaskEnqueue:_playHook: executing ${json.length} tasks`);
|
||||||
|
|
||||||
// check for 'leave' verb and only execute tasks up till then
|
// check for 'leave' verb and only execute tasks up till then
|
||||||
const tasksToRun = [];
|
const tasksToRun = [];
|
||||||
for (const o of tasks) {
|
let leave = false;
|
||||||
if (o.name === TaskName.Leave) {
|
for (const o of json) {
|
||||||
this._leave = true;
|
if (o.verb === TaskName.Leave) {
|
||||||
|
leave = true;
|
||||||
this.logger.info('waitHook returned a leave task');
|
this.logger.info('waitHook returned a leave task');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
tasksToRun.push(o);
|
tasksToRun.push(o);
|
||||||
}
|
}
|
||||||
const cloneTasks = [...tasksToRun];
|
|
||||||
if (this.killed) return [];
|
if (this.killed) return [];
|
||||||
else if (tasksToRun.length > 0) {
|
else if (tasksToRun.length > 0) {
|
||||||
|
const tasks = normalizeJambones(this.logger, tasksToRun).map((tdata) => makeTask(this.logger, tdata));
|
||||||
this._playSession = new ConfirmCallSession({
|
this._playSession = new ConfirmCallSession({
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
application: cs.application,
|
application: cs.application,
|
||||||
dlg,
|
dlg,
|
||||||
ep: cs.ep,
|
ep: cs.ep,
|
||||||
callInfo: cs.callInfo,
|
callInfo: cs.callInfo,
|
||||||
accountInfo: cs.accountInfo,
|
tasks
|
||||||
tasks: tasksToRun,
|
|
||||||
rootSpan: cs.rootSpan
|
|
||||||
});
|
});
|
||||||
await this._playSession.exec();
|
await this._playSession.exec();
|
||||||
this._playSession = null;
|
this._playSession = null;
|
||||||
}
|
}
|
||||||
if (this._leave) {
|
if (leave) {
|
||||||
this.state = QueueResults.Leave;
|
this.state = QueueResults.Leave;
|
||||||
this.kill(cs);
|
this.kill(cs);
|
||||||
}
|
}
|
||||||
return cloneTasks;
|
return tasksToRun;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,498 +1,121 @@
|
|||||||
const {
|
const Task = require('./task');
|
||||||
TaskName,
|
const {TaskName, TaskPreconditions, TranscriptionEvents} = require('../utils/constants');
|
||||||
GoogleTranscriptionEvents,
|
|
||||||
NuanceTranscriptionEvents,
|
|
||||||
AwsTranscriptionEvents,
|
|
||||||
AzureTranscriptionEvents,
|
|
||||||
DeepgramTranscriptionEvents,
|
|
||||||
SonioxTranscriptionEvents,
|
|
||||||
CobaltTranscriptionEvents,
|
|
||||||
IbmTranscriptionEvents,
|
|
||||||
NvidiaTranscriptionEvents,
|
|
||||||
JambonzTranscriptionEvents,
|
|
||||||
AssemblyAiTranscriptionEvents
|
|
||||||
} = require('../utils/constants.json');
|
|
||||||
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 makeTask = require('./make_task');
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
const SttTask = require('./stt-task');
|
|
||||||
|
|
||||||
class TaskGather extends SttTask {
|
class TaskGather extends Task {
|
||||||
constructor(logger, opts, parentTask) {
|
constructor(logger, opts) {
|
||||||
super(logger, opts, parentTask);
|
super(logger, opts);
|
||||||
|
this.preconditions = TaskPreconditions.Endpoint;
|
||||||
|
|
||||||
[
|
[
|
||||||
'finishOnKey', 'input', 'numDigits', 'minDigits', 'maxDigits',
|
'finishOnKey', 'hints', 'input', 'numDigits',
|
||||||
'interDigitTimeout', 'partialResultHook', 'bargein', 'dtmfBargein',
|
'partialResultHook', 'profanityFilter',
|
||||||
'speechTimeout', 'timeout', 'say', 'play'
|
'speechTimeout', 'timeout', 'say', 'play'
|
||||||
].forEach((k) => this[k] = this.data[k]);
|
].forEach((k) => this[k] = this.data[k]);
|
||||||
|
|
||||||
/* when collecting dtmf, bargein on dtmf is true unless explicitly set to false */
|
this.timeout = (this.timeout || 5) * 1000;
|
||||||
if (this.dtmfBargein !== false && this.input.includes('digits')) this.dtmfBargein = true;
|
this.interim = this.partialResultCallback;
|
||||||
|
|
||||||
/* timeout of zero means no timeout */
|
|
||||||
this.timeout = this.timeout === 0 ? 0 : (this.timeout || 15) * 1000;
|
|
||||||
this.interim = !!this.partialResultHook || this.bargein || (this.timeout > 0);
|
|
||||||
this.listenDuringPrompt = this.data.listenDuringPrompt === false ? false : true;
|
|
||||||
this.minBargeinWordCount = this.data.minBargeinWordCount || 1;
|
|
||||||
if (this.data.recognizer) {
|
if (this.data.recognizer) {
|
||||||
/* continuous ASR (i.e. compile transcripts until a special timeout or dtmf key) */
|
this.language = this.data.recognizer.language || 'en-US';
|
||||||
this.asrTimeout = typeof this.data.recognizer.asrTimeout === 'number' ?
|
this.vendor = this.data.recognizer.vendor;
|
||||||
this.data.recognizer.asrTimeout * 1000 : 0;
|
|
||||||
if (this.asrTimeout > 0) {
|
|
||||||
this.isContinuousAsr = true;
|
|
||||||
this.asrDtmfTerminationDigit = this.data.recognizer.asrDtmfTerminationDigit;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
// fast Recognition, fire event after a specified time after the last hypothesis.
|
|
||||||
this.fastRecognitionTimeout = typeof this.data.recognizer.fastRecognitionTimeout === 'number' ?
|
|
||||||
this.data.recognizer.fastRecognitionTimeout * 1000 : 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.digitBuffer = '';
|
this.digitBuffer = '';
|
||||||
this._earlyMedia = this.data.earlyMedia === true;
|
this._earlyMedia = this.data.earlyMedia === true;
|
||||||
|
|
||||||
if (this.say) {
|
if (this.say) this.sayTask = makeTask(this.logger, {say: this.say}, this);
|
||||||
this.sayTask = makeTask(this.logger, {say: this.say}, this);
|
if (this.play) this.playTask = makeTask(this.logger, {play: this.play}, this);
|
||||||
}
|
|
||||||
if (this.play) {
|
|
||||||
this.playTask = makeTask(this.logger, {play: this.play}, this);
|
|
||||||
}
|
|
||||||
if (!this.sayTask && !this.playTask) this.listenDuringPrompt = false;
|
|
||||||
|
|
||||||
/* buffer speech for continuous asr */
|
|
||||||
this._bufferedTranscripts = [];
|
|
||||||
this.partialTranscriptsCount = 0;
|
|
||||||
this.bugname_prefix = 'gather_';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() { return TaskName.Gather; }
|
get name() { return TaskName.Gather; }
|
||||||
|
|
||||||
get needsStt() { return this.input.includes('speech'); }
|
|
||||||
|
|
||||||
get wantsSingleUtterance() {
|
|
||||||
return this.data.recognizer?.singleUtterance === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
get earlyMedia() {
|
get earlyMedia() {
|
||||||
return (this.sayTask && this.sayTask.earlyMedia) ||
|
return (this.sayTask && this.sayTask.earlyMedia) ||
|
||||||
(this.playTask && this.playTask.earlyMedia);
|
(this.playTask && this.playTask.earlyMedia);
|
||||||
}
|
}
|
||||||
|
|
||||||
get summary() {
|
async exec(cs, ep) {
|
||||||
let s = `${this.name}{`;
|
await super.exec(cs);
|
||||||
if (this.input.length === 2) s += 'inputs=[speech,digits],';
|
this.ep = ep;
|
||||||
else if (this.input.includes('digits')) s += 'inputs=digits';
|
|
||||||
else s += 'inputs=speech,';
|
|
||||||
|
|
||||||
if (this.input.includes('speech')) {
|
|
||||||
s += `vendor=${this.vendor || 'default'},language=${this.language || 'default'}`;
|
|
||||||
}
|
|
||||||
if (this.sayTask) s += ',with nested say task';
|
|
||||||
if (this.playTask) s += ',with nested play task';
|
|
||||||
s += '}';
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
async exec(cs, {ep}) {
|
|
||||||
this.logger.debug({options: this.data}, 'Gather:exec');
|
|
||||||
await super.exec(cs, {ep});
|
|
||||||
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
|
|
||||||
|
|
||||||
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 (!this.isContinuousAsr && cs.isContinuousAsr) {
|
|
||||||
this.isContinuousAsr = true;
|
|
||||||
this.asrTimeout = cs.asrTimeout * 1000;
|
|
||||||
this.asrDtmfTerminationDigit = cs.asrDtmfTerminationDigit;
|
|
||||||
this.logger.debug({
|
|
||||||
asrTimeout: this.asrTimeout,
|
|
||||||
asrDtmfTerminationDigit: this.asrDtmfTerminationDigit
|
|
||||||
}, 'Gather:exec - enabling continuous ASR since it is turned on for the session');
|
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
const startListening = async(cs, ep) => {
|
|
||||||
this._startTimer();
|
|
||||||
if (this.isContinuousAsr && 0 === this.timeout) this._startAsrTimer();
|
|
||||||
if (this.input.includes('speech') && !this.listenDuringPrompt) {
|
|
||||||
try {
|
|
||||||
await this._setSpeechHandlers(cs, ep);
|
|
||||||
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 (e) {
|
|
||||||
if (this.fallbackVendor && this.isHandledByPrimaryProvider) {
|
|
||||||
await this._fallback();
|
|
||||||
startListening(cs, ep);
|
|
||||||
} else {
|
|
||||||
this.logger.error({error: e}, 'error in initSpeech');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (this.sayTask) {
|
if (this.sayTask) {
|
||||||
const {span, ctx} = this.startChildSpan(`nested:${this.sayTask.summary}`);
|
this.sayTask.exec(cs, ep); // kicked off, _not_ waiting for it to complete
|
||||||
this.sayTask.span = span;
|
|
||||||
this.sayTask.ctx = ctx;
|
|
||||||
this.sayTask.exec(cs, {ep}); // kicked off, _not_ waiting for it to complete
|
|
||||||
this.sayTask.on('playDone', (err) => {
|
this.sayTask.on('playDone', (err) => {
|
||||||
span.end();
|
if (!this.killed) this._startTimer();
|
||||||
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.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) {
|
else if (this.playTask) {
|
||||||
const {span, ctx} = this.startChildSpan(`nested:${this.playTask.summary}`);
|
this.playTask.exec(cs, ep); // kicked off, _not_ waiting for it to complete
|
||||||
this.playTask.span = span;
|
|
||||||
this.playTask.ctx = ctx;
|
|
||||||
this.playTask.exec(cs, {ep}); // kicked off, _not_ waiting for it to complete
|
|
||||||
this.playTask.on('playDone', (err) => {
|
this.playTask.on('playDone', (err) => {
|
||||||
span.end();
|
if (!this.killed) this._startTimer();
|
||||||
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.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 {
|
else this._startTimer();
|
||||||
if (this.killed) {
|
|
||||||
this.logger.info('Gather:exec - task was immediately killed so do not transcribe');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
startListening(cs, ep);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.input.includes('speech') && this.listenDuringPrompt) {
|
if (this.input.includes('speech')) {
|
||||||
await this._setSpeechHandlers(cs, ep);
|
await this._initSpeech(ep);
|
||||||
this._startTranscribing(ep);
|
this._startTranscribing(ep);
|
||||||
updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid)
|
|
||||||
.catch(() => {/*already logged error */});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.input.includes('digits') || this.dtmfBargein || this.asrDtmfTerminationDigit) {
|
if (this.input.includes('digits')) {
|
||||||
ep.on('dtmf', this._onDtmf.bind(this, cs, ep));
|
ep.on('dtmf', this._onDtmf.bind(this, ep));
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.awaitTaskDone();
|
await this.awaitTaskDone();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(err, 'TaskGather:exec error');
|
this.logger.error(err, 'TaskGather:exec error');
|
||||||
}
|
}
|
||||||
this.removeCustomEventListeners();
|
ep.removeCustomEventListener(TranscriptionEvents.Transcription);
|
||||||
|
ep.removeCustomEventListener(TranscriptionEvents.EndOfUtterance);
|
||||||
}
|
}
|
||||||
|
|
||||||
kill(cs) {
|
kill(cs) {
|
||||||
super.kill(cs);
|
super.kill(cs);
|
||||||
this._killAudio(cs);
|
this._killAudio();
|
||||||
this.ep.removeAllListeners('dtmf');
|
|
||||||
clearTimeout(this.interDigitTimer);
|
|
||||||
this._clearAsrTimer();
|
|
||||||
this.playTask?.span.end();
|
|
||||||
this.sayTask?.span.end();
|
|
||||||
this._resolve('killed');
|
this._resolve('killed');
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTaskInProgress(opts) {
|
_onDtmf(ep, evt) {
|
||||||
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) {
|
|
||||||
this.logger.debug(evt, 'TaskGather:_onDtmf');
|
this.logger.debug(evt, 'TaskGather:_onDtmf');
|
||||||
clearTimeout(this.interDigitTimer);
|
if (evt.dtmf === this.finishOnKey) this._resolve('dtmf-terminator-key');
|
||||||
let resolved = false;
|
else {
|
||||||
if (this.dtmfBargein) {
|
|
||||||
this._killAudio(cs);
|
|
||||||
this.emit('dtmf', evt);
|
|
||||||
}
|
|
||||||
if (evt.dtmf === this.finishOnKey && this.input.includes('digits')) {
|
|
||||||
resolved = true;
|
|
||||||
this._resolve('dtmf-terminator-key');
|
|
||||||
}
|
|
||||||
else if (this.input.includes('digits')) {
|
|
||||||
if (this.digitBuffer.length === 0 && this.needsStt) {
|
|
||||||
// DTMF is higher priority than STT.
|
|
||||||
this.removeCustomEventListeners();
|
|
||||||
ep.stopTranscription({
|
|
||||||
vendor: this.vendor,
|
|
||||||
bugname: this.bugname,
|
|
||||||
})
|
|
||||||
.catch((err) => this.logger.error({err},
|
|
||||||
` Received DTMF, Error stopping transcription for vendor ${this.vendor}`));
|
|
||||||
}
|
|
||||||
this.digitBuffer += evt.dtmf;
|
this.digitBuffer += evt.dtmf;
|
||||||
const len = this.digitBuffer.length;
|
if (this.digitBuffer.length === this.numDigits) this._resolve('dtmf-num-digits');
|
||||||
if (len === this.numDigits || len === this.maxDigits) {
|
|
||||||
resolved = true;
|
|
||||||
this._resolve('dtmf-num-digits');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (this.isContinuousAsr && evt.dtmf === this.asrDtmfTerminationDigit) {
|
|
||||||
this.logger.info(`continuousAsr triggered with dtmf ${this.asrDtmfTerminationDigit}`);
|
|
||||||
this._clearAsrTimer();
|
|
||||||
this._clearTimer();
|
|
||||||
this._startFinalAsrTimer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!resolved && this.interDigitTimeout > 0 && this.digitBuffer.length >= this.minDigits) {
|
|
||||||
/* start interDigitTimer */
|
|
||||||
const ms = this.interDigitTimeout * 1000;
|
|
||||||
this.logger.debug(`starting interdigit timer of ${ms}`);
|
|
||||||
this.interDigitTimer = setTimeout(() => this._resolve('dtmf-interdigit-timeout'), ms);
|
|
||||||
}
|
}
|
||||||
|
this._killAudio();
|
||||||
}
|
}
|
||||||
|
|
||||||
async _setSpeechHandlers(cs, ep) {
|
async _initSpeech(ep) {
|
||||||
if (this._speechHandlersSet) return;
|
const opts = {
|
||||||
this._speechHandlersSet = true;
|
GOOGLE_SPEECH_USE_ENHANCED: true,
|
||||||
|
GOOGLE_SPEECH_SINGLE_UTTERANCE: true,
|
||||||
/* some special deepgram logic */
|
GOOGLE_SPEECH_MODEL: 'command_and_search'
|
||||||
if (this.vendor === 'deepgram') {
|
};
|
||||||
if (this.isContinuousAsr) this._doContinuousAsrWithDeepgram(this.asrTimeout);
|
if (this.hints) {
|
||||||
if (this.data.recognizer?.deepgramOptions?.shortUtterance) this.shortUtterance = true;
|
Object.assign(opts, {'GOOGLE_SPEECH_HINTS': this.hints.join(',')});
|
||||||
}
|
}
|
||||||
|
if (this.profanityFilter === true) {
|
||||||
const opts = this.setChannelVarsForStt(this, this.sttCredentials, this.data.recognizer);
|
Object.assign(opts, {'GOOGLE_SPEECH_PROFANITY_FILTER': true});
|
||||||
switch (this.vendor) {
|
|
||||||
case 'google':
|
|
||||||
this.bugname = `${this.bugname_prefix}google_transcribe`;
|
|
||||||
this.addCustomEventListener(
|
|
||||||
ep, GoogleTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(
|
|
||||||
ep, GoogleTranscriptionEvents.EndOfUtterance, this._onEndOfUtterance.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(
|
|
||||||
ep, GoogleTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'aws':
|
|
||||||
case 'polly':
|
|
||||||
this.bugname = `${this.bugname_prefix}aws_transcribe`;
|
|
||||||
this.addCustomEventListener(ep, AwsTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, AwsTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep));
|
|
||||||
break;
|
|
||||||
case 'microsoft':
|
|
||||||
this.bugname = `${this.bugname_prefix}azure_transcribe`;
|
|
||||||
this.addCustomEventListener(
|
|
||||||
ep, AzureTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, AzureTranscriptionEvents.NoSpeechDetected,
|
|
||||||
this._onNoSpeechDetected.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, AzureTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep));
|
|
||||||
break;
|
|
||||||
case 'nuance':
|
|
||||||
this.bugname = `${this.bugname_prefix}nuance_transcribe`;
|
|
||||||
this.addCustomEventListener(ep, NuanceTranscriptionEvents.Transcription,
|
|
||||||
this._onTranscription.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, NuanceTranscriptionEvents.StartOfSpeech,
|
|
||||||
this._onStartOfSpeech.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, NuanceTranscriptionEvents.TranscriptionComplete,
|
|
||||||
this._onTranscriptionComplete.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, NuanceTranscriptionEvents.VadDetected,
|
|
||||||
this._onVadDetected.bind(this, cs, ep));
|
|
||||||
|
|
||||||
/* stall timers until prompt finishes playing */
|
|
||||||
if ((this.sayTask || this.playTask) && this.listenDuringPrompt) {
|
|
||||||
opts.NUANCE_STALL_TIMERS = 1;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'deepgram':
|
|
||||||
this.bugname = `${this.bugname_prefix}deepgram_transcribe`;
|
|
||||||
this.addCustomEventListener(
|
|
||||||
ep, DeepgramTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, DeepgramTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, DeepgramTranscriptionEvents.ConnectFailure,
|
|
||||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
|
||||||
|
|
||||||
/* if app sets deepgramOptions.utteranceEndMs they essentially want continuous asr */
|
|
||||||
if (opts.DEEPGRAM_SPEECH_UTTERANCE_END_MS) this.isContinuousAsr = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'soniox':
|
|
||||||
this.bugname = `${this.bugname_prefix}soniox_transcribe`;
|
|
||||||
this.addCustomEventListener(
|
|
||||||
ep, SonioxTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'cobalt':
|
|
||||||
this.bugname = `${this.bugname_prefix}cobalt_transcribe`;
|
|
||||||
this.addCustomEventListener(
|
|
||||||
ep, CobaltTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
|
||||||
|
|
||||||
/* cobalt doesnt have language, it has model, which is required */
|
|
||||||
if (!this.data.recognizer.model) {
|
|
||||||
throw new Error('Cobalt requires a model to be specified');
|
|
||||||
}
|
|
||||||
this.language = this.data.recognizer.model;
|
|
||||||
|
|
||||||
/* special case: if using hints with cobalt we need to compile them */
|
|
||||||
this.hostport = opts.COBALT_SERVER_URI;
|
|
||||||
if (this.vendor === 'cobalt' && opts.COBALT_SPEECH_HINTS) {
|
|
||||||
try {
|
|
||||||
const context = await this.compileHintsForCobalt(
|
|
||||||
ep,
|
|
||||||
this.hostport,
|
|
||||||
this.data.recognizer.model,
|
|
||||||
opts.COBALT_CONTEXT_TOKEN,
|
|
||||||
opts.COBALT_SPEECH_HINTS
|
|
||||||
);
|
|
||||||
if (context) opts.COBALT_COMPILED_CONTEXT_DATA = context;
|
|
||||||
delete opts.COBALT_SPEECH_HINTS;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error({err}, 'Error compiling hints for cobalt');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delete opts.COBALT_SERVER_URI;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'ibm':
|
|
||||||
this.bugname = `${this.bugname_prefix}ibm_transcribe`;
|
|
||||||
this.addCustomEventListener(ep, IbmTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, IbmTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, IbmTranscriptionEvents.ConnectFailure,
|
|
||||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'nvidia':
|
|
||||||
this.bugname = `${this.bugname_prefix}nvidia_transcribe`;
|
|
||||||
this.addCustomEventListener(ep, NvidiaTranscriptionEvents.Transcription,
|
|
||||||
this._onTranscription.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, NvidiaTranscriptionEvents.StartOfSpeech,
|
|
||||||
this._onStartOfSpeech.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, NvidiaTranscriptionEvents.TranscriptionComplete,
|
|
||||||
this._onTranscriptionComplete.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, 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;
|
|
||||||
|
|
||||||
case 'assemblyai':
|
|
||||||
this.bugname = `${this.bugname_prefix}assemblyai_transcribe`;
|
|
||||||
this.addCustomEventListener(ep, AssemblyAiTranscriptionEvents.Transcription,
|
|
||||||
this._onTranscription.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(
|
|
||||||
ep, AssemblyAiTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, AssemblyAiTranscriptionEvents.Error, this._onVendorError.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, AssemblyAiTranscriptionEvents.ConnectFailure,
|
|
||||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
if (this.vendor.startsWith('custom:')) {
|
|
||||||
this.bugname = `${this.bugname_prefix}${this.vendor}_transcribe`;
|
|
||||||
this.addCustomEventListener(
|
|
||||||
ep, JambonzTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, JambonzTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, JambonzTranscriptionEvents.ConnectFailure,
|
|
||||||
this._onVendorConnectFailure.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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
this.logger.debug(`setting freeswitch vars ${JSON.stringify(opts)}`);
|
||||||
/* common handler for all stt engine errors */
|
|
||||||
this.addCustomEventListener(ep, JambonzTranscriptionEvents.Error, this._onJambonzError.bind(this, cs, ep));
|
|
||||||
await ep.set(opts)
|
await ep.set(opts)
|
||||||
.catch((err) => this.logger.info(err, 'Error setting channel variables'));
|
.catch((err) => this.logger.info(err, 'Error set'));
|
||||||
|
ep.addCustomEventListener(TranscriptionEvents.Transcription, this._onTranscription.bind(this, ep));
|
||||||
|
ep.addCustomEventListener(TranscriptionEvents.EndOfUtterance, this._onEndOfUtterance.bind(this, ep));
|
||||||
}
|
}
|
||||||
|
|
||||||
_startTranscribing(ep) {
|
_startTranscribing(ep) {
|
||||||
this.logger.debug({
|
|
||||||
vendor: this.vendor,
|
|
||||||
locale: this.language,
|
|
||||||
interim: this.interim,
|
|
||||||
bugname: this.bugname
|
|
||||||
}, 'Gather:_startTranscribing');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Note: we don't need to ask deepgram for interim results, because they
|
|
||||||
* already send us words as they are finalized (is_final=true) even before
|
|
||||||
* the utterance is finalized (speech_final=true)
|
|
||||||
*/
|
|
||||||
ep.startTranscription({
|
ep.startTranscription({
|
||||||
vendor: this.vendor,
|
interim: this.partialResultCallback ? true : false,
|
||||||
locale: this.language,
|
language: this.language || this.callSession.speechRecognizerLanguage
|
||||||
interim: this.interim,
|
}).catch((err) => this.logger.error(err, 'TaskGather:_startTranscribing error'));
|
||||||
bugname: this.bugname,
|
|
||||||
hostport: this.hostport,
|
|
||||||
}).catch((err) => {
|
|
||||||
const {writeAlerts, AlertType} = this.cs.srf.locals;
|
|
||||||
this.logger.error(err, 'TaskGather:_startTranscribing error');
|
|
||||||
writeAlerts({
|
|
||||||
account_sid: this.cs.accountSid,
|
|
||||||
alert_type: AlertType.STT_FAILURE,
|
|
||||||
vendor: this.vendor,
|
|
||||||
detail: err.message
|
|
||||||
});
|
|
||||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for tts failure'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_startTimer() {
|
_startTimer() {
|
||||||
if (0 === this.timeout) return;
|
assert(!this._timeoutTimer);
|
||||||
this._clearTimer();
|
this._timeoutTimer = setTimeout(() => this._resolve('timeout'), this.timeout);
|
||||||
this._timeoutTimer = setTimeout(() => {
|
|
||||||
if (this.isContinuousAsr) this._startAsrTimer();
|
|
||||||
else if (this.interDigitTimeout <= 0 ||
|
|
||||||
this.digitBuffer.length < this.minDigits ||
|
|
||||||
this.needsStt && this.digitBuffer.length === 0) {
|
|
||||||
this._resolve(this.digitBuffer.length >= this.minDigits ? 'dtmf-num-digits' : 'timeout');
|
|
||||||
}
|
|
||||||
}, this.timeout);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_clearTimer() {
|
_clearTimer() {
|
||||||
@@ -502,396 +125,46 @@ class TaskGather extends SttTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_startAsrTimer() {
|
_killAudio() {
|
||||||
if (this.vendor === 'deepgram') return; // no need
|
|
||||||
assert(this.isContinuousAsr);
|
|
||||||
this._clearAsrTimer();
|
|
||||||
this._asrTimer = setTimeout(() => {
|
|
||||||
this.logger.debug('_startAsrTimer - asr timer went off');
|
|
||||||
const evt = this.consolidateTranscripts(this._bufferedTranscripts, 1, this.language);
|
|
||||||
this._resolve(this._bufferedTranscripts.length > 0 ? 'speech' : 'timeout', evt);
|
|
||||||
}, this.asrTimeout);
|
|
||||||
this.logger.debug(`_startAsrTimer: set for ${this.asrTimeout}ms`);
|
|
||||||
}
|
|
||||||
|
|
||||||
_clearAsrTimer() {
|
|
||||||
if (this._asrTimer) clearTimeout(this._asrTimer);
|
|
||||||
this._asrTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_startFastRecognitionTimer(evt) {
|
|
||||||
assert(this.fastRecognitionTimeout > 0);
|
|
||||||
this._clearFastRecognitionTimer();
|
|
||||||
this._fastRecognitionTimer = setTimeout(() => {
|
|
||||||
evt.is_final = true;
|
|
||||||
this._resolve('speech', evt);
|
|
||||||
}, this.fastRecognitionTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
_clearFastRecognitionTimer() {
|
|
||||||
if (this._fastRecognitionTimer) {
|
|
||||||
clearTimeout(this._fastRecognitionTimer);
|
|
||||||
}
|
|
||||||
this._fastRecognitionTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_startFinalAsrTimer() {
|
|
||||||
this._clearFinalAsrTimer();
|
|
||||||
this._finalAsrTimer = setTimeout(() => {
|
|
||||||
this.logger.debug('_startFinalAsrTimer - final asr timer went off');
|
|
||||||
const evt = this.consolidateTranscripts(this._bufferedTranscripts, 1, this.language);
|
|
||||||
this._resolve(this._bufferedTranscripts.length > 0 ? 'speech' : 'timeout', evt);
|
|
||||||
}, 1000);
|
|
||||||
this.logger.debug('_startFinalAsrTimer: set for 1 second');
|
|
||||||
}
|
|
||||||
|
|
||||||
_clearFinalAsrTimer() {
|
|
||||||
if (this._finalAsrTimer) clearTimeout(this._finalAsrTimer);
|
|
||||||
this._finalAsrTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_killAudio(cs) {
|
|
||||||
if (!this.sayTask && !this.playTask && this.bargein) {
|
|
||||||
if (this.ep?.connected && !this.playComplete) {
|
|
||||||
this.logger.debug('Gather:_killAudio: killing playback of any audio');
|
|
||||||
this.playComplete = true;
|
|
||||||
this.ep.api('uuid_break', this.ep.uuid)
|
|
||||||
.catch((err) => this.logger.info(err, 'Error killing audio'));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.sayTask && !this.sayTask.killed) {
|
if (this.sayTask && !this.sayTask.killed) {
|
||||||
this.sayTask.removeAllListeners('playDone');
|
this.sayTask.removeAllListeners('playDone');
|
||||||
this.sayTask.kill(cs);
|
this.sayTask.kill();
|
||||||
this.sayTask = null;
|
|
||||||
}
|
}
|
||||||
if (this.playTask && !this.playTask.killed) {
|
if (this.playTask && !this.playTask.killed) {
|
||||||
this.playTask.removeAllListeners('playDone');
|
this.playTask.removeAllListeners('playDone');
|
||||||
this.playTask.kill(cs);
|
this.playTask.kill();
|
||||||
this.playTask = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_onTranscription(cs, ep, evt, fsEvent) {
|
_onTranscription(ep, evt) {
|
||||||
// make sure this is not a transcript from answering machine detection
|
this.logger.debug(evt, 'TaskGather:_onTranscription');
|
||||||
const bugname = fsEvent.getHeader('media-bugname');
|
if (evt.is_final) this._resolve('speech', evt);
|
||||||
const finished = fsEvent.getHeader('transcription-session-finished');
|
else if (this.partialResultHook) {
|
||||||
this.logger.debug({evt, bugname, finished}, `Gather:_onTranscription for vendor ${this.vendor}`);
|
this.cs.requestor.request(this.partialResultHook, Object.assign({speech: evt}, this.cs.callInfo))
|
||||||
if (bugname && this.bugname !== bugname) return;
|
.catch((err) => this.logger.info(err, 'GatherTask:_onTranscription error'));
|
||||||
|
|
||||||
if (this.vendor === 'ibm' && evt?.state === 'listening') return;
|
|
||||||
|
|
||||||
if (this.vendor === 'deepgram' && evt.type === 'UtteranceEnd') {
|
|
||||||
/* we will only get this when we have set utterance_end_ms */
|
|
||||||
if (this._bufferedTranscripts.length === 0) {
|
|
||||||
this.logger.debug('Gather:_onTranscription - got UtteranceEnd event from deepgram but no buffered transcripts');
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.logger.debug('Gather:_onTranscription - got UtteranceEnd event from deepgram, return buffered transcript');
|
|
||||||
evt = this.consolidateTranscripts(this._bufferedTranscripts, 1, this.language);
|
|
||||||
this._bufferedTranscripts = [];
|
|
||||||
this._resolve('speech', evt);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
evt = this.normalizeTranscription(evt, this.vendor, 1, this.language,
|
|
||||||
this.shortUtterance, this.data.recognizer.punctuation);
|
|
||||||
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);
|
|
||||||
|
|
||||||
let emptyTranscript = false;
|
|
||||||
if (evt.is_final) {
|
|
||||||
if (evt.alternatives[0].transcript === '' && !this.callSession.callGone && !this.killed) {
|
|
||||||
emptyTranscript = true;
|
|
||||||
if (finished === 'true' && ['microsoft', 'deepgram'].includes(this.vendor)) {
|
|
||||||
this.logger.debug({evt}, 'TaskGather:_onTranscription - got empty transcript from old gather, disregarding');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
else if (this.vendor !== 'deepgram') {
|
|
||||||
this.logger.info({evt}, 'TaskGather:_onTranscription - got empty transcript, continue listening');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
else if (this.isContinuousAsr) {
|
|
||||||
this.logger.info({evt},
|
|
||||||
'TaskGather:_onTranscription - got empty deepgram transcript during continous asr, continue listening');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
else if (this.vendor === 'deepgram' && this._bufferedTranscripts.length > 0) {
|
|
||||||
this.logger.info({evt},
|
|
||||||
'TaskGather:_onTranscription - got empty transcript from deepgram, return the buffered transcripts');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.logger.info({evt}, 'TaskGather:_onTranscription - got transcript during continous asr');
|
|
||||||
this._bufferedTranscripts.push(evt);
|
|
||||||
this._clearTimer();
|
|
||||||
if (this._finalAsrTimer) {
|
|
||||||
this._clearFinalAsrTimer();
|
|
||||||
const evt = this.consolidateTranscripts(this._bufferedTranscripts, 1, this.language);
|
|
||||||
return this._resolve(this._bufferedTranscripts.length > 0 ? 'speech' : 'timeout', evt);
|
|
||||||
}
|
|
||||||
this._startAsrTimer();
|
|
||||||
|
|
||||||
/* 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 = [];
|
|
||||||
}
|
|
||||||
else if (this.vendor === 'deepgram') {
|
|
||||||
/* compile transcripts into one */
|
|
||||||
if (!emptyTranscript) this._bufferedTranscripts.push(evt);
|
|
||||||
if (this.data.recognizer?.deepgramOptions?.utteranceEndMs) {
|
|
||||||
this.logger.debug('TaskGather:_onTranscription - got speech_final waiting for UtteranceEnd event');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.logger.debug({evt}, 'TaskGather:_onTranscription - compiling deepgram transcripts');
|
|
||||||
evt = this.consolidateTranscripts(this._bufferedTranscripts, 1, this.language);
|
|
||||||
this._bufferedTranscripts = [];
|
|
||||||
this.logger.debug({evt}, 'TaskGather:_onTranscription - compiled deepgram transcripts');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* here is where we return a final transcript */
|
|
||||||
this._resolve('speech', evt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
this._killAudio(cs);
|
|
||||||
}
|
|
||||||
if (this.fastRecognitionTimeout) {
|
|
||||||
this._startFastRecognitionTimer(evt);
|
|
||||||
}
|
|
||||||
if (this.partialResultHook) {
|
|
||||||
const b3 = this.getTracingPropagation();
|
|
||||||
const httpHeaders = b3 && {b3};
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* deepgram can send a non-final transcript but with words that are final, so we need to buffer */
|
|
||||||
if (this.vendor === 'deepgram') {
|
|
||||||
const originalEvent = evt.vendor.evt;
|
|
||||||
if (originalEvent.is_final && evt.alternatives[0].transcript !== '') {
|
|
||||||
this.logger.debug({evt}, 'Gather:_onTranscription - buffering a completed (partial) deepgram transcript');
|
|
||||||
this._bufferedTranscripts.push(evt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_onEndOfUtterance(cs, ep) {
|
_onEndOfUtterance(ep, evt) {
|
||||||
this.logger.debug('TaskGather:_onEndOfUtterance');
|
this.logger.info(evt, 'TaskGather:_onEndOfUtterance');
|
||||||
if (this.bargein && this.minBargeinWordCount === 0) {
|
this._startTranscribing(ep);
|
||||||
this._killAudio(cs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 user 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');
|
|
||||||
}
|
|
||||||
async _onJambonzError(cs, ep, evt) {
|
|
||||||
this.logger.info({evt}, 'TaskGather:_onJambonzError');
|
|
||||||
if (this.isHandledByPrimaryProvider && this.fallbackVendor) {
|
|
||||||
ep.stopTranscription({
|
|
||||||
vendor: this.vendor,
|
|
||||||
bugname: this.bugname
|
|
||||||
})
|
|
||||||
.catch((err) => this.logger.error({err}, `Error stopping transcription for primary vendor ${this.vendor}`));
|
|
||||||
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
|
|
||||||
try {
|
|
||||||
await this._fallback();
|
|
||||||
await this._initSpeech(cs, ep);
|
|
||||||
this._startTranscribing(ep);
|
|
||||||
updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid);
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.info({error}, `There is error while falling back to ${this.fallbackVendor}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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}`});
|
|
||||||
}
|
|
||||||
|
|
||||||
_onVendorConnectFailure(cs, _ep, evt) {
|
|
||||||
super._onVendorConnectFailure(cs, _ep, evt);
|
|
||||||
this.notifyTaskDone();
|
|
||||||
}
|
|
||||||
|
|
||||||
_onVendorError(cs, _ep, evt) {
|
|
||||||
super._onVendorError(cs, _ep, evt);
|
|
||||||
this._resolve('stt-error', evt);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onVadDetected(cs, ep) {
|
|
||||||
if (this.bargein && this.minBargeinWordCount === 0) {
|
|
||||||
this.logger.debug('TaskGather:_onVadDetected');
|
|
||||||
this._killAudio(cs);
|
|
||||||
this.emit('vad');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_onNoSpeechDetected(cs, ep, evt, fsEvent) {
|
|
||||||
if (!this.callSession.callGone && !this.killed) {
|
|
||||||
const finished = fsEvent.getHeader('transcription-session-finished');
|
|
||||||
if (this.vendor === 'microsoft' && finished === 'true') {
|
|
||||||
this.logger.debug('TaskGather:_onNoSpeechDetected for old gather, ignoring');
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.logger.debug('TaskGather:_onNoSpeechDetected - listen again');
|
|
||||||
this._startTranscribing(ep);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async _resolve(reason, evt) {
|
async _resolve(reason, evt) {
|
||||||
this.logger.debug(`TaskGather:resolve with reason ${reason}`);
|
|
||||||
if (this.resolved) return;
|
if (this.resolved) return;
|
||||||
|
|
||||||
this.resolved = true;
|
this.resolved = true;
|
||||||
// Clear dtmf event
|
this.logger.debug(`TaskGather:resolve with reason ${reason}`);
|
||||||
if (this.dtmfBargein) {
|
|
||||||
this.ep.removeAllListeners('dtmf');
|
if (this.ep && this.ep.connected) {
|
||||||
|
this.ep.stopTranscription().catch((err) => this.logger.error({err}, 'Error stopping transcription'));
|
||||||
}
|
}
|
||||||
clearTimeout(this.interDigitTimer);
|
|
||||||
this._clearTimer();
|
this._clearTimer();
|
||||||
this._clearFastRecognitionTimer();
|
if (reason.startsWith('dtmf')) {
|
||||||
|
await this.performAction({digits: this.digitBuffer});
|
||||||
this.span.setAttributes({
|
|
||||||
channel: 1,
|
|
||||||
'stt.resolve': reason,
|
|
||||||
'stt.result': JSON.stringify(evt)
|
|
||||||
});
|
|
||||||
if (this.needsStt && this.ep && this.ep.connected) {
|
|
||||||
this.ep.stopTranscription({
|
|
||||||
vendor: this.vendor,
|
|
||||||
bugname: this.bugname
|
|
||||||
})
|
|
||||||
.catch((err) => this.logger.error({err}, 'Error stopping transcription'));
|
|
||||||
}
|
}
|
||||||
|
else if (reason.startsWith('speech')) {
|
||||||
if (this.callSession && this.callSession.callGone) {
|
await this.performAction({speech: evt});
|
||||||
this.logger.debug('TaskGather:_resolve - call is gone, not invoking web callback');
|
|
||||||
this.notifyTaskDone();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
if (reason.startsWith('dtmf')) {
|
|
||||||
if (this.parentTask) this.parentTask.emit('dtmf', evt);
|
|
||||||
else {
|
|
||||||
this.emit('dtmf', evt);
|
|
||||||
await this.performAction({digits: this.digitBuffer, reason: 'dtmfDetected'});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (reason.startsWith('speech')) {
|
|
||||||
if (this.parentTask) this.parentTask.emit('transcription', evt);
|
|
||||||
else {
|
|
||||||
this.emit('transcription', evt);
|
|
||||||
await this.performAction({speech: evt, reason: 'speechDetected'});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (reason.startsWith('timeout')) {
|
|
||||||
if (this.parentTask) this.parentTask.emit('timeout', evt);
|
|
||||||
else {
|
|
||||||
this.emit('timeout', evt);
|
|
||||||
await this.performAction({reason: 'timeout'});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (reason.startsWith('stt-error')) {
|
|
||||||
if (this.parentTask) this.parentTask.emit('stt-error', evt);
|
|
||||||
else {
|
|
||||||
this.emit('stt-error', evt);
|
|
||||||
await this.performAction({reason: 'error', details: evt.error});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) { /*already logged error*/ }
|
|
||||||
this.notifyTaskDone();
|
this.notifyTaskDone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,10 @@ class TaskHangup extends Task {
|
|||||||
/**
|
/**
|
||||||
* Hangup the call
|
* Hangup the call
|
||||||
*/
|
*/
|
||||||
async exec(cs, {dlg}) {
|
async exec(cs, dlg) {
|
||||||
await super.exec(cs);
|
await super.exec(cs);
|
||||||
try {
|
try {
|
||||||
await dlg.destroy({headers: this.headers});
|
await dlg.destroy({headers: this.headers});
|
||||||
cs._callReleased();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(err, 'TaskHangup:exec - Error hanging up call');
|
this.logger.error(err, 'TaskHangup:exec - Error hanging up call');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class TaskLeave extends Task {
|
|||||||
|
|
||||||
get name() { return TaskName.Leave; }
|
get name() { return TaskName.Leave; }
|
||||||
|
|
||||||
async exec(cs, {ep}) {
|
async exec(cs, ep) {
|
||||||
await super.exec(cs);
|
await super.exec(cs);
|
||||||
await this.awaitTaskDone();
|
await this.awaitTaskDone();
|
||||||
}
|
}
|
||||||
|
|||||||
350
lib/tasks/lex.js
350
lib/tasks/lex.js
@@ -1,350 +0,0 @@
|
|||||||
const Task = require('./task');
|
|
||||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
|
||||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
|
||||||
|
|
||||||
class Lex extends Task {
|
|
||||||
constructor(logger, opts) {
|
|
||||||
super(logger, opts);
|
|
||||||
this.preconditions = TaskPreconditions.Endpoint;
|
|
||||||
|
|
||||||
if (this.data.credentials) {
|
|
||||||
this.awsAccessKeyId = this.data.credentials.accessKey;
|
|
||||||
this.awsSecretAccessKey = this.data.credentials.secretAccessKey;
|
|
||||||
}
|
|
||||||
this.bot = this.data.botId;
|
|
||||||
this.alias = this.data.botAlias;
|
|
||||||
this.region = this.data.region;
|
|
||||||
this.locale = this.data.locale || 'en_US';
|
|
||||||
this.intent = this.data.intent || {};
|
|
||||||
this.metadata = this.data.metadata;
|
|
||||||
this.welcomeMessage = this.data.welcomeMessage;
|
|
||||||
this.bargein = this.data.bargein || false;
|
|
||||||
this.passDtmf = this.data.passDtmf || false;
|
|
||||||
if (this.data.noInputTimeout) this.noInputTimeout = this.data.noInputTimeout * 1000;
|
|
||||||
if (this.data.tts) {
|
|
||||||
this.vendor = this.data.tts.vendor || 'default';
|
|
||||||
this.language = this.data.tts.language || 'default';
|
|
||||||
this.voice = this.data.tts.voice || 'default';
|
|
||||||
this.speechCredentialLabel = this.data.tts.label || 'default';
|
|
||||||
|
|
||||||
// fallback tts
|
|
||||||
this.fallbackVendor = this.data.tts.fallbackVendor || 'default';
|
|
||||||
this.fallbackLanguage = this.data.tts.fallbackLanguage || 'default';
|
|
||||||
this.fallbackVoice = this.data.tts.fallbackLanguage || 'default';
|
|
||||||
this.fallbackLabel = this.data.tts.fallbackLabel || 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
this.botName = `${this.bot}:${this.alias}:${this.region}`;
|
|
||||||
if (this.data.eventHook) this.eventHook = this.data.eventHook;
|
|
||||||
this.events = this.eventHook ?
|
|
||||||
[
|
|
||||||
'intent',
|
|
||||||
'transcription',
|
|
||||||
'dtmf',
|
|
||||||
'start-play',
|
|
||||||
'stop-play',
|
|
||||||
'play-interrupted',
|
|
||||||
'response-text'
|
|
||||||
] : [];
|
|
||||||
if (this.data.actionHook) this.actionHook = this.data.actionHook;
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() { return TaskName.Lex; }
|
|
||||||
|
|
||||||
async exec(cs, {ep}) {
|
|
||||||
await super.exec(cs);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.init(cs, ep);
|
|
||||||
|
|
||||||
// kick it off
|
|
||||||
const obj = {};
|
|
||||||
let cmd = `${this.ep.uuid} ${this.bot} ${this.alias} ${this.region} ${this.locale} `;
|
|
||||||
|
|
||||||
if (this.metadata) Object.assign(obj, this.metadata);
|
|
||||||
if (this.intent.name) {
|
|
||||||
cmd += this.intent.name;
|
|
||||||
if (this.intent.slots) Object.assign(obj, {slots: this.intent.slots});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(obj).length > 0) cmd += ` '${JSON.stringify(obj)}'`;
|
|
||||||
|
|
||||||
this.logger.debug({cmd}, `starting lex bot ${this.botName} with locale ${this.locale}`);
|
|
||||||
this.ep.api('aws_lex_start', cmd)
|
|
||||||
.catch((err) => {
|
|
||||||
this.logger.error({err}, `Error starting lex bot ${this.botName}`);
|
|
||||||
this.notifyTaskDone();
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.awaitTaskDone();
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error({err}, 'Lex:exec error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async kill(cs) {
|
|
||||||
super.kill(cs);
|
|
||||||
if (this.ep.connected) {
|
|
||||||
this.logger.debug('Lex:kill');
|
|
||||||
this.ep.removeCustomEventListener('lex::intent');
|
|
||||||
this.ep.removeCustomEventListener('lex::transcription');
|
|
||||||
this.ep.removeCustomEventListener('lex::audio_provided');
|
|
||||||
this.ep.removeCustomEventListener('lex::text_response');
|
|
||||||
this.ep.removeCustomEventListener('lex::playback_interruption');
|
|
||||||
this.ep.removeCustomEventListener('lex::error');
|
|
||||||
this.ep.removeAllListeners('dtmf');
|
|
||||||
|
|
||||||
this.performAction({lexResult: 'caller hungup'})
|
|
||||||
.catch((err) => this.logger.error({err}, 'lex - error w/ action webook'));
|
|
||||||
|
|
||||||
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
|
||||||
}
|
|
||||||
this.notifyTaskDone();
|
|
||||||
}
|
|
||||||
|
|
||||||
async init(cs, ep) {
|
|
||||||
this.ep = ep;
|
|
||||||
try {
|
|
||||||
if (this.vendor === 'default') {
|
|
||||||
this.vendor = cs.speechSynthesisVendor;
|
|
||||||
this.language = cs.speechSynthesisLanguage;
|
|
||||||
this.voice = cs.speechSynthesisVoice;
|
|
||||||
this.speechCredentialLabel = cs.speechSynthesisLabel;
|
|
||||||
}
|
|
||||||
if (this.fallbackVendor === 'default') {
|
|
||||||
this.fallbackVendor = cs.fallbackSpeechSynthesisVendor;
|
|
||||||
this.fallbackLanguage = cs.fallbackSpeechSynthesisLanguage;
|
|
||||||
this.fallbackVoice = cs.fallbackSpeechSynthesisVoice;
|
|
||||||
this.fallbackLabel = cs.fallbackSpeechSynthesisLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ttsCredentials = cs.getSpeechCredentials(this.vendor, 'tts', this.speechCredentialLabel);
|
|
||||||
|
|
||||||
this.ep.addCustomEventListener('lex::intent', this._onIntent.bind(this, ep, cs));
|
|
||||||
this.ep.addCustomEventListener('lex::transcription', this._onTranscription.bind(this, ep, cs));
|
|
||||||
this.ep.addCustomEventListener('lex::audio_provided', this._onAudioProvided.bind(this, ep, cs));
|
|
||||||
this.ep.addCustomEventListener('lex::text_response', this._onTextResponse.bind(this, ep, cs));
|
|
||||||
this.ep.addCustomEventListener('lex::playback_interruption', this._onPlaybackInterruption.bind(this, ep, cs));
|
|
||||||
this.ep.addCustomEventListener('lex::error', this._onError.bind(this, ep, cs));
|
|
||||||
this.ep.on('dtmf', this._onDtmf.bind(this, ep, cs));
|
|
||||||
|
|
||||||
const channelVars = {};
|
|
||||||
if (this.bargein) {
|
|
||||||
Object.assign(channelVars, {'x-amz-lex:barge-in-enabled': 1});
|
|
||||||
}
|
|
||||||
if (this.noInputTimeout) {
|
|
||||||
Object.assign(channelVars, {'x-amz-lex:audio:start-timeout-ms': this.noInputTimeout});
|
|
||||||
}
|
|
||||||
if (this.awsAccessKeyId && this.awsSecretAccessKey) {
|
|
||||||
Object.assign(channelVars, {
|
|
||||||
AWS_ACCESS_KEY_ID: this.awsAccessKeyId,
|
|
||||||
AWS_SECRET_ACCESS_KEY: this.awsSecretAccessKey
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (this.vendor) Object.assign(channelVars, {LEX_USE_TTS: 1});
|
|
||||||
//if (this.intent.name) Object.assign(channelVars, {LEX_WELCOME_INTENT: this.intent});
|
|
||||||
if (this.welcomeMessage && this.welcomeMessage.length) {
|
|
||||||
Object.assign(channelVars, {LEX_WELCOME_MESSAGE: this.welcomeMessage});
|
|
||||||
}
|
|
||||||
if (Object.keys(channelVars).length) await this.ep.set(channelVars);
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error({err}, 'Error setting listeners');
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An intent has been returned.
|
|
||||||
* we may get an empty intent, signified by ...
|
|
||||||
* In such a case, we just restart the bot.
|
|
||||||
* @param {*} ep - media server endpoint
|
|
||||||
* @param {*} evt - event data
|
|
||||||
*/
|
|
||||||
_onIntent(ep, cs, evt) {
|
|
||||||
this.logger.debug({evt}, `got intent for ${this.botName}`);
|
|
||||||
if (this.events.includes('intent')) {
|
|
||||||
this._performHook(cs, this.eventHook, {event: 'intent', data: evt});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A transcription - either interim or final - has been returned.
|
|
||||||
* If we are doing barge-in based on hotword detection, check for the hotword or phrase.
|
|
||||||
* If we are playing a filler sound, like typing, during the fullfillment phase, start that
|
|
||||||
* if this is a final transcript.
|
|
||||||
* @param {*} ep - media server endpoint
|
|
||||||
* @param {*} evt - event data
|
|
||||||
*/
|
|
||||||
_onTranscription(ep, cs, evt) {
|
|
||||||
this.logger.debug({evt}, `got transcription for ${this.botName}`);
|
|
||||||
if (this.events.includes('transcription')) {
|
|
||||||
this._performHook(cs, this.eventHook, {event: 'transcription', data: evt});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _fallbackSynthAudio(cs, msg, stats, synthAudio) {
|
|
||||||
try {
|
|
||||||
const {filePath} = await synthAudio(stats, {
|
|
||||||
account_sid: cs.accountSid,
|
|
||||||
text: msg,
|
|
||||||
vendor: this.vendor,
|
|
||||||
language: this.language,
|
|
||||||
voice: this.voice,
|
|
||||||
salt: cs.callSid,
|
|
||||||
credentials: this.ttsCredentials
|
|
||||||
});
|
|
||||||
|
|
||||||
return filePath;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.info({error}, 'failed to synth audio from primary vendor');
|
|
||||||
if (this.fallbackVendor) {
|
|
||||||
try {
|
|
||||||
const credential = cs.getSpeechCredentials(this.fallbackVendor, 'tts', this.fallbackLabel);
|
|
||||||
const {filePath} = await synthAudio(stats, {
|
|
||||||
account_sid: cs.accountSid,
|
|
||||||
text: msg,
|
|
||||||
vendor: this.fallbackVendor,
|
|
||||||
language: this.fallbackLanguage,
|
|
||||||
voice: this.fallbackVoice,
|
|
||||||
salt: cs.callSid,
|
|
||||||
credentials: credential
|
|
||||||
});
|
|
||||||
return filePath;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info({err}, 'failed to synth audio from fallback vendor');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {*} evt - event data
|
|
||||||
*/
|
|
||||||
async _onTextResponse(ep, cs, evt) {
|
|
||||||
this.logger.debug({evt}, `got text response for ${this.botName}`);
|
|
||||||
const messages = evt.messages;
|
|
||||||
if (this.events.includes('response-text')) {
|
|
||||||
this._performHook(cs, this.eventHook, {event: 'response-text', data: evt});
|
|
||||||
}
|
|
||||||
if (this.vendor && Array.isArray(messages) && messages.length) {
|
|
||||||
const msg = messages[0].msg;
|
|
||||||
const type = messages[0].type;
|
|
||||||
if (['PlainText', 'SSML'].includes(type) && msg) {
|
|
||||||
const {srf} = cs;
|
|
||||||
const {stats} = srf.locals;
|
|
||||||
const {synthAudio} = srf.locals.dbHelpers;
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.logger.debug(`tts with ${this.vendor} ${this.voice}`);
|
|
||||||
const filePath = await this._fallbackSynthAudio(cs, msg, stats, synthAudio);
|
|
||||||
if (filePath) cs.trackTmpFile(filePath);
|
|
||||||
|
|
||||||
if (this.events.includes('start-play')) {
|
|
||||||
this._performHook(cs, this.eventHook, {event: 'start-play', data: {path: filePath}});
|
|
||||||
}
|
|
||||||
await ep.play(filePath);
|
|
||||||
if (this.events.includes('stop-play')) {
|
|
||||||
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: filePath}});
|
|
||||||
}
|
|
||||||
this.logger.debug(`finished tts, sending play_done ${this.vendor} ${this.voice}`);
|
|
||||||
this.ep.api('aws_lex_play_done', this.ep.uuid)
|
|
||||||
.catch((err) => {
|
|
||||||
this.logger.error({err}, `Error sending play_done ${this.botName}`);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error({err}, 'Lex:_onTextResponse - error playing tts');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {*} evt - event data
|
|
||||||
*/
|
|
||||||
_onPlaybackInterruption(ep, cs, evt) {
|
|
||||||
this.logger.debug({evt}, `got playback interruption for ${this.botName}`);
|
|
||||||
if (this.bargein) {
|
|
||||||
if (this.events.includes('play-interrupted')) {
|
|
||||||
this._performHook(cs, this.eventHook, {event: 'play-interrupted', data: {}});
|
|
||||||
}
|
|
||||||
this.ep.api('uuid_break', this.ep.uuid)
|
|
||||||
.catch((err) => this.logger.info(err, 'Lex::_onPlaybackInterruption - Error killing audio'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lex has returned an error of some kind.
|
|
||||||
* @param {*} evt - event data
|
|
||||||
*/
|
|
||||||
_onError(ep, cs, evt) {
|
|
||||||
this.logger.error({evt}, `got error for bot ${this.botName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Audio has been received from lex and written to a temporary disk file.
|
|
||||||
* Start playing the audio, after killing any filler sound that might be playing.
|
|
||||||
* When the audio completes, start the no-input timer.
|
|
||||||
* @param {*} ep - media server endpoint
|
|
||||||
* @param {*} evt - event data
|
|
||||||
*/
|
|
||||||
async _onAudioProvided(ep, cs, evt) {
|
|
||||||
if (this.vendor) return;
|
|
||||||
|
|
||||||
this.waitingForPlayStart = false;
|
|
||||||
this.logger.debug({evt}, `got audio file for bot ${this.botName}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (this.events.includes('start-play')) {
|
|
||||||
this._performHook(cs, this.eventHook, {event: 'start-play', data: {path: evt.path}});
|
|
||||||
}
|
|
||||||
await ep.play(evt.path);
|
|
||||||
if (this.events.includes('stop-play')) {
|
|
||||||
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: evt.path}});
|
|
||||||
}
|
|
||||||
this.logger.debug({evt}, `done playing audio file for bot ${this.botName}`);
|
|
||||||
this.ep.api('aws_lex_play_done', this.ep.uuid)
|
|
||||||
.catch((err) => {
|
|
||||||
this.logger.error({err}, `Error sending play_done ${this.botName}`);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error({err}, `Error playing file ${evt.path} for both ${this.botName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* receive a dmtf entry from the caller.
|
|
||||||
* If we have active dtmf instructions, collect and process accordingly.
|
|
||||||
*/
|
|
||||||
_onDtmf(ep, cs, evt) {
|
|
||||||
this.logger.debug({evt}, 'Lex:_onDtmf');
|
|
||||||
if (this.events.includes('dtmf')) {
|
|
||||||
this._performHook(cs, this.eventHook, {event: 'dtmf', data: evt});
|
|
||||||
}
|
|
||||||
if (this.passDtmf) {
|
|
||||||
this.ep.api('aws_lex_dtmf', `${this.ep.uuid} ${evt.dtmf}`)
|
|
||||||
.catch((err) => {
|
|
||||||
this.logger.error({err}, `Error sending dtmf ${evt.dtmf} ${this.botName}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _performHook(cs, hook, results) {
|
|
||||||
const b3 = this.getTracingPropagation();
|
|
||||||
const httpHeaders = b3 && {b3};
|
|
||||||
const json = await this.cs.requestor.request('verb:hook', hook, results, httpHeaders);
|
|
||||||
if (json && Array.isArray(json)) {
|
|
||||||
const makeTask = require('./make_task');
|
|
||||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
|
||||||
if (tasks && tasks.length > 0) {
|
|
||||||
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
|
|
||||||
this.performAction({lexResult: 'redirect'}, false);
|
|
||||||
cs.replaceApplication(tasks);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = Lex;
|
|
||||||
@@ -2,18 +2,15 @@ const Task = require('./task');
|
|||||||
const {TaskName, TaskPreconditions, ListenEvents, ListenStatus} = require('../utils/constants');
|
const {TaskName, TaskPreconditions, ListenEvents, ListenStatus} = require('../utils/constants');
|
||||||
const makeTask = require('./make_task');
|
const makeTask = require('./make_task');
|
||||||
const moment = require('moment');
|
const moment = require('moment');
|
||||||
const MAX_PLAY_AUDIO_QUEUE_SIZE = 10;
|
|
||||||
const DTMF_SPAN_NAME = 'dtmf';
|
|
||||||
|
|
||||||
class TaskListen extends Task {
|
class TaskListen extends Task {
|
||||||
constructor(logger, opts, parentTask) {
|
constructor(logger, opts, parentTask) {
|
||||||
super(logger, opts);
|
super(logger, opts);
|
||||||
this.disableBidirectionalAudio = opts.disableBidirectionalAudio;
|
|
||||||
this.preconditions = TaskPreconditions.Endpoint;
|
this.preconditions = TaskPreconditions.Endpoint;
|
||||||
|
|
||||||
[
|
[
|
||||||
'action', 'auth', 'method', 'url', 'finishOnKey', 'maxLength', 'metadata', 'mixType', 'passDtmf', 'playBeep',
|
'action', 'auth', 'method', 'url', 'finishOnKey', 'maxLength', 'metadata', 'mixType', 'passDtmf', 'playBeep',
|
||||||
'sampleRate', 'timeout', 'transcribe', 'wsAuth', 'disableBidirectionalAudio'
|
'sampleRate', 'timeout', 'transcribe', 'wsAuth'
|
||||||
].forEach((k) => this[k] = this.data[k]);
|
].forEach((k) => this[k] = this.data[k]);
|
||||||
|
|
||||||
this.mixType = this.mixType || 'mono';
|
this.mixType = this.mixType || 'mono';
|
||||||
@@ -23,22 +20,17 @@ class TaskListen extends Task {
|
|||||||
this.nested = parentTask instanceof Task;
|
this.nested = parentTask instanceof Task;
|
||||||
|
|
||||||
this.results = {};
|
this.results = {};
|
||||||
this.playAudioQueue = [];
|
|
||||||
this.isPlayingAudioFromQueue = false;
|
|
||||||
|
|
||||||
if (this.transcribe) this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe}, this);
|
if (this.transcribe) this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe}, this);
|
||||||
|
|
||||||
|
this._dtmfHandler = this._onDtmf.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() { return TaskName.Listen; }
|
get name() { return TaskName.Listen; }
|
||||||
|
|
||||||
set bugname(name) { this._bugname = name; }
|
async exec(cs, ep) {
|
||||||
|
|
||||||
set ignoreCustomerData(val) { this._ignoreCustomerData = val; }
|
|
||||||
|
|
||||||
async exec(cs, {ep}) {
|
|
||||||
await super.exec(cs);
|
await super.exec(cs);
|
||||||
this.ep = ep;
|
this.ep = ep;
|
||||||
this._dtmfHandler = this._onDtmf.bind(this, ep);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.hook = this.normalizeUrl(this.url, 'GET', this.wsAuth);
|
this.hook = this.normalizeUrl(this.url, 'GET', this.wsAuth);
|
||||||
@@ -46,12 +38,7 @@ class TaskListen extends Task {
|
|||||||
if (this.playBeep) await this._playBeep(ep);
|
if (this.playBeep) await this._playBeep(ep);
|
||||||
if (this.transcribeTask) {
|
if (this.transcribeTask) {
|
||||||
this.logger.debug('TaskListen:exec - starting nested transcribe task');
|
this.logger.debug('TaskListen:exec - starting nested transcribe task');
|
||||||
const {span, ctx} = this.startChildSpan(`nested:${this.transcribeTask.summary}`);
|
this.transcribeTask.exec(cs, ep);
|
||||||
this.transcribeTask.span = span;
|
|
||||||
this.transcribeTask.ctx = ctx;
|
|
||||||
this.transcribeTask.exec(cs, {ep})
|
|
||||||
.then((result) => span.end())
|
|
||||||
.catch((err) => span.end());
|
|
||||||
}
|
}
|
||||||
await this._startListening(cs, ep);
|
await this._startListening(cs, ep);
|
||||||
await this.awaitTaskDone();
|
await this.awaitTaskDone();
|
||||||
@@ -67,41 +54,28 @@ class TaskListen extends Task {
|
|||||||
super.kill(cs);
|
super.kill(cs);
|
||||||
this.logger.debug(`TaskListen:kill endpoint connected? ${this.ep && this.ep.connected}`);
|
this.logger.debug(`TaskListen:kill endpoint connected? ${this.ep && this.ep.connected}`);
|
||||||
this._clearTimer();
|
this._clearTimer();
|
||||||
this.playAudioQueue = [];
|
|
||||||
if (this.ep && this.ep.connected) {
|
if (this.ep && this.ep.connected) {
|
||||||
this.logger.debug('TaskListen:kill closing websocket');
|
this.logger.debug('TaskListen:kill closing websocket');
|
||||||
try {
|
await this.ep.forkAudioStop()
|
||||||
const args = this._bugname ? [this._bugname] : [];
|
.catch((err) => this.logger.info(err, 'TaskListen:kill'));
|
||||||
await this.ep.forkAudioStop(...args);
|
|
||||||
this.logger.debug('TaskListen:kill successfully closed websocket');
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info(err, 'TaskListen:kill');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (this.recordStartTime) {
|
if (this.recordStartTime) {
|
||||||
const duration = moment().diff(this.recordStartTime, 'seconds');
|
const duration = moment().diff(this.recordStartTime, 'seconds');
|
||||||
this.results.dialCallDuration = duration;
|
this.results.dialCallDuration = duration;
|
||||||
}
|
}
|
||||||
if (this.transcribeTask) {
|
if (this.transcribeTask) await this.transcribeTask.kill(cs);
|
||||||
await this.transcribeTask.kill(cs);
|
|
||||||
this.transcribeTask = null;
|
|
||||||
}
|
|
||||||
this.ep && this._removeListeners(this.ep);
|
|
||||||
this.notifyTaskDone();
|
this.notifyTaskDone();
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateListen(status) {
|
async updateListen(status) {
|
||||||
if (!this.killed && this.ep && this.ep.connected) {
|
if (!this.killed && this.ep && this.ep.connected) {
|
||||||
const args = this._bugname ? [this._bugname] : [];
|
|
||||||
this.logger.info(`TaskListen:updateListen status ${status}`);
|
this.logger.info(`TaskListen:updateListen status ${status}`);
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case ListenStatus.Pause:
|
case ListenStatus.Pause:
|
||||||
await this.ep.forkAudioPause(...args)
|
await this.ep.forkAudioPause().catch((err) => this.logger.info(err, 'TaskListen: error pausing audio'));
|
||||||
.catch((err) => this.logger.info(err, 'TaskListen: error pausing audio'));
|
|
||||||
break;
|
break;
|
||||||
case ListenStatus.Resume:
|
case ListenStatus.Resume:
|
||||||
await this.ep.forkAudioResume(...args)
|
await this.ep.forkAudioResume().catch((err) => this.logger.info(err, 'TaskListen: error resuming audio'));
|
||||||
.catch((err) => this.logger.info(err, 'TaskListen: error resuming audio'));
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,13 +88,9 @@ class TaskListen extends Task {
|
|||||||
|
|
||||||
async _startListening(cs, ep) {
|
async _startListening(cs, ep) {
|
||||||
this._initListeners(ep);
|
this._initListeners(ep);
|
||||||
const ci = this.nested ? this.parentTask.sd.callInfo : cs.callInfo.toJSON();
|
|
||||||
if (this._ignoreCustomerData) {
|
|
||||||
delete ci.customerData;
|
|
||||||
}
|
|
||||||
const metadata = Object.assign(
|
const metadata = Object.assign(
|
||||||
{sampleRate: this.sampleRate, mixType: this.mixType},
|
{sampleRate: this.sampleRate, mixType: this.mixType},
|
||||||
ci,
|
this.nested ? this.parentTask.sd.callInfo : cs.callInfo.toJSON(),
|
||||||
this.metadata);
|
this.metadata);
|
||||||
if (this.hook.auth) {
|
if (this.hook.auth) {
|
||||||
this.logger.debug({username: this.hook.auth.username, password: this.hook.auth.password},
|
this.logger.debug({username: this.hook.auth.username, password: this.hook.auth.password},
|
||||||
@@ -134,7 +104,6 @@ class TaskListen extends Task {
|
|||||||
wsUrl: this.hook.url,
|
wsUrl: this.hook.url,
|
||||||
mixType: this.mixType,
|
mixType: this.mixType,
|
||||||
sampling: this.sampleRate,
|
sampling: this.sampleRate,
|
||||||
...(this._bugname && {bugname: this._bugname}),
|
|
||||||
metadata
|
metadata
|
||||||
});
|
});
|
||||||
this.recordStartTime = moment();
|
this.recordStartTime = moment();
|
||||||
@@ -153,13 +122,6 @@ class TaskListen extends Task {
|
|||||||
if (this.finishOnKey || this.passDtmf) {
|
if (this.finishOnKey || this.passDtmf) {
|
||||||
ep.on('dtmf', this._dtmfHandler);
|
ep.on('dtmf', this._dtmfHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* support bi-directional audio */
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_removeListeners(ep) {
|
_removeListeners(ep) {
|
||||||
@@ -169,32 +131,9 @@ class TaskListen extends Task {
|
|||||||
if (this.finishOnKey || this.passDtmf) {
|
if (this.finishOnKey || this.passDtmf) {
|
||||||
ep.removeListener('dtmf', this._dtmfHandler);
|
ep.removeListener('dtmf', this._dtmfHandler);
|
||||||
}
|
}
|
||||||
ep.removeCustomEventListener(ListenEvents.PlayAudio);
|
|
||||||
ep.removeCustomEventListener(ListenEvents.KillAudio);
|
|
||||||
ep.removeCustomEventListener(ListenEvents.Disconnect);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_onDtmf(ep, evt) {
|
_onDtmf(evt) {
|
||||||
const {dtmf, duration} = evt;
|
|
||||||
this.logger.debug({evt}, `TaskListen:_onDtmf received dtmf ${dtmf}`);
|
|
||||||
if (this.passDtmf && this.ep?.connected) {
|
|
||||||
const obj = {event: 'dtmf', dtmf, duration};
|
|
||||||
const args = this._bugname ? [this._bugname, obj] : [obj];
|
|
||||||
this.ep.forkAudioSendText(...args)
|
|
||||||
.catch((err) => this.logger.info({err}, 'TaskListen:_onDtmf error sending dtmf'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* add a child span for the dtmf event */
|
|
||||||
const msDuration = Math.floor((duration / 8000) * 1000);
|
|
||||||
const {span} = this.startChildSpan(`${DTMF_SPAN_NAME}:${dtmf}`);
|
|
||||||
span.setAttributes({
|
|
||||||
channel: 1,
|
|
||||||
dtmf,
|
|
||||||
duration: `${msDuration}ms`
|
|
||||||
});
|
|
||||||
span.end();
|
|
||||||
|
|
||||||
if (evt.dtmf === this.finishOnKey) {
|
if (evt.dtmf === this.finishOnKey) {
|
||||||
this.logger.info(`TaskListen:_onDtmf terminating task due to dtmf ${evt.dtmf}`);
|
this.logger.info(`TaskListen:_onDtmf terminating task due to dtmf ${evt.dtmf}`);
|
||||||
this.results.digits = evt.dtmf;
|
this.results.digits = evt.dtmf;
|
||||||
@@ -215,80 +154,11 @@ class TaskListen extends Task {
|
|||||||
this.logger.info(evt, 'TaskListen:_onConnectFailure');
|
this.logger.info(evt, 'TaskListen:_onConnectFailure');
|
||||||
this.notifyTaskDone();
|
this.notifyTaskDone();
|
||||||
}
|
}
|
||||||
|
|
||||||
async _playAudio(ep, evt, logger) {
|
|
||||||
try {
|
|
||||||
const results = await ep.play(evt.file);
|
|
||||||
logger.debug(`Finished playing file, result: ${JSON.stringify(results)}`);
|
|
||||||
const obj = {
|
|
||||||
type: 'playDone',
|
|
||||||
data: {
|
|
||||||
id: evt.id,
|
|
||||||
...results
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const args = this._bugname ? [this._bugname, obj] : [obj];
|
|
||||||
ep.forkAudioSendText(...args);
|
|
||||||
} catch (err) {
|
|
||||||
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) {
|
|
||||||
this.logger.info('received kill_audio event');
|
|
||||||
ep.api('uuid_break', ep.uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onDisconnect(ep, cs) {
|
|
||||||
this.logger.debug('_onDisconnect: TaskListen terminating task');
|
|
||||||
this.kill(cs);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onError(ep, evt) {
|
_onError(ep, evt) {
|
||||||
this.logger.info(evt, 'TaskListen:_onError');
|
this.logger.info(evt, 'TaskListen:_onError');
|
||||||
this.notifyTaskDone();
|
this.notifyTaskDone();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* play or say something during the call
|
|
||||||
* @param {*} tasks - array of play/say tasks to execute
|
|
||||||
*/
|
|
||||||
async whisper(tasks, callSid) {
|
|
||||||
try {
|
|
||||||
const cs = this.callSession;
|
|
||||||
this.logger.debug('Listen:whisper tasks starting');
|
|
||||||
while (tasks.length && !cs.callGone) {
|
|
||||||
const task = tasks.shift();
|
|
||||||
await task.exec(cs, {ep: this.ep});
|
|
||||||
}
|
|
||||||
this.logger.debug('Listen:whisper tasks complete');
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(err, 'Listen:whisper error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = TaskListen;
|
module.exports = TaskListen;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { validateVerb } = require('@jambonz/verb-specifications');
|
const Task = require('./task');
|
||||||
const {TaskName} = require('../utils/constants');
|
const {TaskName} = require('../utils/constants');
|
||||||
const errBadInstruction = new Error('malformed jambonz application payload');
|
const errBadInstruction = new Error('malformed jambonz application payload');
|
||||||
|
|
||||||
@@ -12,21 +12,13 @@ function makeTask(logger, obj, parent) {
|
|||||||
if (typeof data !== 'object') {
|
if (typeof data !== 'object') {
|
||||||
throw errBadInstruction;
|
throw errBadInstruction;
|
||||||
}
|
}
|
||||||
validateVerb(name, data, logger);
|
Task.validate(name, data);
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case TaskName.SipDecline:
|
case TaskName.SipDecline:
|
||||||
const TaskSipDecline = require('./sip_decline');
|
const TaskSipDecline = require('./sip_decline');
|
||||||
return new TaskSipDecline(logger, data, parent);
|
return new TaskSipDecline(logger, data, parent);
|
||||||
case TaskName.SipRequest:
|
|
||||||
const TaskSipRequest = require('./sip_request');
|
|
||||||
return new TaskSipRequest(logger, data, parent);
|
|
||||||
case TaskName.SipRefer:
|
|
||||||
const TaskSipRefer = require('./sip_refer');
|
|
||||||
return new TaskSipRefer(logger, data, parent);
|
|
||||||
case TaskName.Config:
|
|
||||||
const TaskConfig = require('./config');
|
|
||||||
return new TaskConfig(logger, data, parent);
|
|
||||||
case TaskName.Conference:
|
case TaskName.Conference:
|
||||||
|
logger.debug({data}, 'Conference verb');
|
||||||
const TaskConference = require('./conference');
|
const TaskConference = require('./conference');
|
||||||
return new TaskConference(logger, data, parent);
|
return new TaskConference(logger, data, parent);
|
||||||
case TaskName.Dial:
|
case TaskName.Dial:
|
||||||
@@ -38,9 +30,6 @@ function makeTask(logger, obj, parent) {
|
|||||||
case TaskName.Dequeue:
|
case TaskName.Dequeue:
|
||||||
const TaskDequeue = require('./dequeue');
|
const TaskDequeue = require('./dequeue');
|
||||||
return new TaskDequeue(logger, data, parent);
|
return new TaskDequeue(logger, data, parent);
|
||||||
case TaskName.Dtmf:
|
|
||||||
const TaskDtmf = require('./dtmf');
|
|
||||||
return new TaskDtmf(logger, data, parent);
|
|
||||||
case TaskName.Enqueue:
|
case TaskName.Enqueue:
|
||||||
const TaskEnqueue = require('./enqueue');
|
const TaskEnqueue = require('./enqueue');
|
||||||
return new TaskEnqueue(logger, data, parent);
|
return new TaskEnqueue(logger, data, parent);
|
||||||
@@ -50,15 +39,6 @@ function makeTask(logger, obj, parent) {
|
|||||||
case TaskName.Leave:
|
case TaskName.Leave:
|
||||||
const TaskLeave = require('./leave');
|
const TaskLeave = require('./leave');
|
||||||
return new TaskLeave(logger, data, parent);
|
return new TaskLeave(logger, data, parent);
|
||||||
case TaskName.Lex:
|
|
||||||
const TaskLex = require('./lex');
|
|
||||||
return new TaskLex(logger, data, parent);
|
|
||||||
case TaskName.Message:
|
|
||||||
const TaskMessage = require('./message');
|
|
||||||
return new TaskMessage(logger, data, parent);
|
|
||||||
case TaskName.Rasa:
|
|
||||||
const TaskRasa = require('./rasa');
|
|
||||||
return new TaskRasa(logger, data, parent);
|
|
||||||
case TaskName.Say:
|
case TaskName.Say:
|
||||||
const TaskSay = require('./say');
|
const TaskSay = require('./say');
|
||||||
return new TaskSay(logger, data, parent);
|
return new TaskSay(logger, data, parent);
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
const Task = require('./task');
|
|
||||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
|
||||||
const bent = require('bent');
|
|
||||||
const uuidv4 = require('uuid-random');
|
|
||||||
const {K8S} = require('../config');
|
|
||||||
class TaskMessage extends Task {
|
|
||||||
constructor(logger, opts) {
|
|
||||||
super(logger, opts);
|
|
||||||
this.preconditions = TaskPreconditions.None;
|
|
||||||
|
|
||||||
this.payload = {
|
|
||||||
message_sid: this.data.message_sid || uuidv4(),
|
|
||||||
carrier: this.data.carrier,
|
|
||||||
to: this.data.to,
|
|
||||||
from: this.data.from,
|
|
||||||
text: this.data.text
|
|
||||||
};
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() { return TaskName.Message; }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send outbound SMS
|
|
||||||
*/
|
|
||||||
async exec(cs) {
|
|
||||||
const {srf, accountSid} = cs;
|
|
||||||
const {res} = cs.callInfo;
|
|
||||||
let payload = this.payload;
|
|
||||||
const actionParams = {message_sid: this.payload.message_sid};
|
|
||||||
|
|
||||||
await super.exec(cs);
|
|
||||||
try {
|
|
||||||
const {getSmpp, dbHelpers} = srf.locals;
|
|
||||||
const {lookupSmppGateways} = dbHelpers;
|
|
||||||
|
|
||||||
this.logger.debug(`looking up gateways for account_sid: ${accountSid}`);
|
|
||||||
const r = await lookupSmppGateways(accountSid);
|
|
||||||
let gw, url, relativeUrl;
|
|
||||||
if (r.length > 0) {
|
|
||||||
gw = r.find((o) => 1 === o.sg.outbound && (!this.payload.carrier || o.vc.name === this.payload.carrier));
|
|
||||||
}
|
|
||||||
if (gw) {
|
|
||||||
this.logger.info({gw, accountSid}, 'Message:exec - using smpp to send message');
|
|
||||||
url = K8S ? 'http://smpp' : getSmpp();
|
|
||||||
relativeUrl = '/sms';
|
|
||||||
payload = {
|
|
||||||
...payload,
|
|
||||||
...gw.sg,
|
|
||||||
...gw.vc
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
//TMP: smpp only at the moment, need to add http back in
|
|
||||||
/*
|
|
||||||
this.logger.info({gw, accountSid, carrier: this.payload.carrier},
|
|
||||||
'Message:exec - no smpp gateways found to send message');
|
|
||||||
relativeUrl = 'v1/outboundSMS';
|
|
||||||
const sbcAddress = getSBC();
|
|
||||||
if (sbcAddress) url = `http://${sbcAddress}:3000/`;
|
|
||||||
*/
|
|
||||||
this.performAction({
|
|
||||||
...actionParams,
|
|
||||||
message_status: 'no carriers'
|
|
||||||
}).catch((err) => {});
|
|
||||||
if (res) res.sendStatus(404);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url) {
|
|
||||||
const post = bent(url, 'POST', 'json', 201, 480);
|
|
||||||
this.logger.info({payload, url}, 'Message:exec sending outbound SMS');
|
|
||||||
const response = await post(relativeUrl, payload);
|
|
||||||
const {smpp_err_code, carrier, message_id, message} = response;
|
|
||||||
if (smpp_err_code) {
|
|
||||||
this.logger.info({response}, 'SMPP error sending SMS');
|
|
||||||
this.performAction({
|
|
||||||
...actionParams,
|
|
||||||
carrier,
|
|
||||||
carrier_message_id: message_id,
|
|
||||||
message_status: 'failure',
|
|
||||||
message_failure_reason: message
|
|
||||||
}).catch((err) => {});
|
|
||||||
if (res) {
|
|
||||||
res.status(480).json({
|
|
||||||
...response,
|
|
||||||
sid: cs.callInfo.messageSid
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const {message_id, carrier} = response;
|
|
||||||
this.logger.info({response}, 'Successfully sent SMS');
|
|
||||||
this.performAction({
|
|
||||||
...actionParams,
|
|
||||||
carrier,
|
|
||||||
carrier_message_id: message_id,
|
|
||||||
message_status: 'success',
|
|
||||||
}).catch((err) => {});
|
|
||||||
if (res) {
|
|
||||||
res.status(200).json({
|
|
||||||
sid: cs.callInfo.messageSid,
|
|
||||||
carrierResponse: response
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.logger.info('Message:exec - unable to send SMS as SMPP is not configured on the system');
|
|
||||||
this.performAction({
|
|
||||||
...actionParams,
|
|
||||||
message_status: 'smpp configuration error'
|
|
||||||
}).catch((err) => {});
|
|
||||||
if (res) res.status(404).json({message: 'no configured SMS gateways'});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(err, 'TaskMessage:exec - unexpected error sending SMS');
|
|
||||||
this.performAction({
|
|
||||||
...actionParams,
|
|
||||||
message_status: 'system error',
|
|
||||||
message_failure_reason: err.message
|
|
||||||
});
|
|
||||||
if (res) res.status(422).json({message: 'no configured SMS gateways'});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = TaskMessage;
|
|
||||||
@@ -10,7 +10,7 @@ class TaskPause extends Task {
|
|||||||
|
|
||||||
get name() { return TaskName.Pause; }
|
get name() { return TaskName.Pause; }
|
||||||
|
|
||||||
async exec(cs) {
|
async exec(cs, ep) {
|
||||||
await super.exec(cs);
|
await super.exec(cs);
|
||||||
this.timer = setTimeout(this.notifyTaskDone.bind(this), this.length * 1000);
|
this.timer = setTimeout(this.notifyTaskDone.bind(this), this.length * 1000);
|
||||||
await this.awaitTaskDone();
|
await this.awaitTaskDone();
|
||||||
|
|||||||
@@ -7,66 +7,20 @@ class TaskPlay extends Task {
|
|||||||
this.preconditions = TaskPreconditions.Endpoint;
|
this.preconditions = TaskPreconditions.Endpoint;
|
||||||
|
|
||||||
this.url = this.data.url;
|
this.url = this.data.url;
|
||||||
this.seekOffset = this.data.seekOffset || -1;
|
|
||||||
this.timeoutSecs = this.data.timeoutSecs || -1;
|
|
||||||
this.loop = this.data.loop || 1;
|
this.loop = this.data.loop || 1;
|
||||||
this.earlyMedia = this.data.earlyMedia === true;
|
this.earlyMedia = this.data.earlyMedia === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() { return TaskName.Play; }
|
get name() { return TaskName.Play; }
|
||||||
|
|
||||||
get summary() {
|
async exec(cs, ep) {
|
||||||
return `${this.name}:{url=${this.url}}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async exec(cs, {ep}) {
|
|
||||||
await super.exec(cs);
|
await super.exec(cs);
|
||||||
this.ep = ep;
|
this.ep = ep;
|
||||||
let timeout;
|
|
||||||
let playbackSeconds = 0;
|
|
||||||
let playbackMilliseconds = 0;
|
|
||||||
let completed = !(this.timeoutSecs > 0 || this.loop);
|
|
||||||
if (this.timeoutSecs > 0) {
|
|
||||||
timeout = setTimeout(async() => {
|
|
||||||
completed = true;
|
|
||||||
try {
|
|
||||||
await this.kill(cs);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info(err, 'Error killing audio on timeoutSecs');
|
|
||||||
}
|
|
||||||
}, this.timeoutSecs * 1000);
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
this.notifyStatus({event: 'start-playback'});
|
while (!this.killed && this.loop--) {
|
||||||
while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep.connected) {
|
await ep.play(this.url);
|
||||||
if (cs.isInConference) {
|
|
||||||
const {memberId, confName, confUuid} = cs;
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (timeout) clearTimeout(timeout);
|
|
||||||
this.logger.info(err, `TaskPlay:exec - error playing ${this.url}`);
|
this.logger.info(err, `TaskPlay:exec - error playing ${this.url}`);
|
||||||
}
|
}
|
||||||
this.emit('playDone');
|
this.emit('playDone');
|
||||||
@@ -76,14 +30,7 @@ class TaskPlay extends Task {
|
|||||||
super.kill(cs);
|
super.kill(cs);
|
||||||
if (this.ep.connected && !this.playComplete) {
|
if (this.ep.connected && !this.playComplete) {
|
||||||
this.logger.debug('TaskPlay:kill - killing audio');
|
this.logger.debug('TaskPlay:kill - killing audio');
|
||||||
if (cs.isInConference) {
|
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||||
const {memberId, confName} = cs;
|
|
||||||
this.killPlayToConfMember(this.ep, memberId, confName);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.notifyStatus({event: 'kill-playback'});
|
|
||||||
this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
const Task = require('./task');
|
|
||||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
|
||||||
const makeTask = require('./make_task');
|
|
||||||
const bent = require('bent');
|
|
||||||
|
|
||||||
class Rasa extends Task {
|
|
||||||
constructor(logger, opts) {
|
|
||||||
super(logger, opts);
|
|
||||||
this.preconditions = TaskPreconditions.Endpoint;
|
|
||||||
|
|
||||||
this.prompt = this.data.prompt;
|
|
||||||
this.eventHook = this.data?.eventHook;
|
|
||||||
this.actionHook = this.data?.actionHook;
|
|
||||||
this.post = bent('POST', 'json', 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() { return TaskName.Rasa; }
|
|
||||||
|
|
||||||
get hasReportedFinalAction() {
|
|
||||||
return this.reportedFinalAction || this.isReplacingApplication;
|
|
||||||
}
|
|
||||||
|
|
||||||
async exec(cs, {ep}) {
|
|
||||||
await super.exec(cs);
|
|
||||||
|
|
||||||
this.ep = ep;
|
|
||||||
try {
|
|
||||||
/* set event handlers */
|
|
||||||
this.on('transcription', this._onTranscription.bind(this, cs, ep));
|
|
||||||
this.on('timeout', this._onTimeout.bind(this, cs, ep));
|
|
||||||
|
|
||||||
/* start the first gather */
|
|
||||||
this.gatherTask = this._makeGatherTask(this.prompt);
|
|
||||||
const {span, ctx} = this.startChildSpan(`nested:${this.gatherTask.summary}`);
|
|
||||||
this.gatherTask.span = span;
|
|
||||||
this.gatherTask.ctx = ctx;
|
|
||||||
this.gatherTask.exec(cs, {ep})
|
|
||||||
.then(() => span.end())
|
|
||||||
.catch((err) => {
|
|
||||||
span.end();
|
|
||||||
this.logger.info({err}, 'Rasa gather task returned error');
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.awaitTaskDone();
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error({err}, 'Rasa error');
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async kill(cs) {
|
|
||||||
super.kill(cs);
|
|
||||||
this.logger.debug('Rasa:kill');
|
|
||||||
|
|
||||||
if (!this.hasReportedFinalAction) {
|
|
||||||
this.reportedFinalAction = true;
|
|
||||||
this.performAction({rasaResult: 'caller hungup'})
|
|
||||||
.catch((err) => this.logger.info({err}, 'rasa - error w/ action webook'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.ep.connected) {
|
|
||||||
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
|
||||||
}
|
|
||||||
this.removeAllListeners();
|
|
||||||
this.notifyTaskDone();
|
|
||||||
}
|
|
||||||
|
|
||||||
_makeGatherTask(prompt) {
|
|
||||||
let opts = {
|
|
||||||
input: ['speech'],
|
|
||||||
timeout: this.data.timeout || 10,
|
|
||||||
recognizer: this.data.recognizer || {
|
|
||||||
vendor: 'default',
|
|
||||||
language: 'default'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (prompt) {
|
|
||||||
const sayOpts = this.data.tts ?
|
|
||||||
{text: prompt, synthesizer: this.data.tts} :
|
|
||||||
{text: prompt};
|
|
||||||
|
|
||||||
opts = {
|
|
||||||
...opts,
|
|
||||||
say: sayOpts
|
|
||||||
};
|
|
||||||
}
|
|
||||||
//this.logger.debug({opts}, 'constructing a nested gather object');
|
|
||||||
const gather = makeTask(this.logger, {gather: opts}, this);
|
|
||||||
return gather;
|
|
||||||
}
|
|
||||||
|
|
||||||
async _onTranscription(cs, ep, evt) {
|
|
||||||
//this.logger.debug({evt}, `Rasa: got transcription for callSid ${cs.callSid}`);
|
|
||||||
const utterance = evt.alternatives[0].transcript;
|
|
||||||
|
|
||||||
if (this.eventHook) {
|
|
||||||
this.performHook(cs, this.eventHook, {event: 'userMessage', message: utterance})
|
|
||||||
.then((redirected) => {
|
|
||||||
if (redirected) {
|
|
||||||
this.logger.info('Rasa_onTranscription: event handler for user message redirected us to new webhook');
|
|
||||||
this.reportedFinalAction = true;
|
|
||||||
this.performAction({rasaResult: 'redirect'}, false);
|
|
||||||
if (this.gatherTask) this.gatherTask.kill(cs);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
})
|
|
||||||
.catch(({err}) => {
|
|
||||||
this.logger.info({err}, 'Rasa_onTranscription: error sending event hook');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
sender: cs.callSid,
|
|
||||||
message: utterance
|
|
||||||
};
|
|
||||||
this.logger.debug({payload}, 'Rasa:_onTranscription - sending payload to Rasa');
|
|
||||||
const response = await this.post(this.data.url, payload);
|
|
||||||
this.logger.debug({response}, 'Rasa:_onTranscription - got response from Rasa');
|
|
||||||
const botUtterance = Array.isArray(response) ?
|
|
||||||
response.reduce((prev, current) => {
|
|
||||||
return current.text ? `${prev} ${current.text}` : '';
|
|
||||||
}, '') :
|
|
||||||
null;
|
|
||||||
if (botUtterance) {
|
|
||||||
this.logger.debug({botUtterance}, 'Rasa:_onTranscription: got user utterance');
|
|
||||||
this.gatherTask = this._makeGatherTask(botUtterance);
|
|
||||||
const {span, ctx} = this.startChildSpan(`nested:${this.gatherTask.summary}`);
|
|
||||||
this.gatherTask.span = span;
|
|
||||||
this.gatherTask.ctx = ctx;
|
|
||||||
this.gatherTask.exec(cs, {ep})
|
|
||||||
.then(() => span.end())
|
|
||||||
.catch((err) => {
|
|
||||||
span.end();
|
|
||||||
this.logger.info({err}, 'Rasa gather task returned error');
|
|
||||||
});
|
|
||||||
if (this.eventHook) {
|
|
||||||
this.performHook(cs, this.eventHook, {event: 'botMessage', message: response})
|
|
||||||
.then((redirected) => {
|
|
||||||
if (redirected) {
|
|
||||||
this.logger.info('Rasa_onTranscription: event handler for bot message redirected us to new webhook');
|
|
||||||
this.reportedFinalAction = true;
|
|
||||||
this.performAction({rasaResult: 'redirect'}, false);
|
|
||||||
if (this.gatherTask) this.gatherTask.kill(cs);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
})
|
|
||||||
.catch(({err}) => {
|
|
||||||
this.logger.info({err}, 'Rasa_onTranscription: error sending event hook');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error({err}, 'Rasa_onTranscription: Error sending user utterance to Rasa - ending task');
|
|
||||||
this.performAction({rasaResult: 'webhookError'});
|
|
||||||
this.reportedFinalAction = true;
|
|
||||||
this.notifyTaskDone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_onTimeout(cs, ep, evt) {
|
|
||||||
this.logger.debug({evt}, 'Rasa: got timeout');
|
|
||||||
if (!this.hasReportedFinalAction) this.performAction({rasaResult: 'timeout'});
|
|
||||||
this.reportedFinalAction = true;
|
|
||||||
this.notifyTaskDone();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = Rasa;
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
const Task = require('./task');
|
const Task = require('./task');
|
||||||
const {TaskName} = require('../utils/constants');
|
const {TaskName} = require('../utils/constants');
|
||||||
const makeTask = require('./make_task');
|
const makeTask = require('./make_task');
|
||||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
const normalizeJambones = require('../utils/normalize-jambones');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages an outdial made via REST API
|
* Manages an outdial made via REST API
|
||||||
@@ -11,12 +11,9 @@ class TaskRestDial extends Task {
|
|||||||
super(logger, opts);
|
super(logger, opts);
|
||||||
|
|
||||||
this.from = this.data.from;
|
this.from = this.data.from;
|
||||||
this.callerName = this.data.callerName;
|
|
||||||
this.fromHost = this.data.fromHost;
|
|
||||||
this.to = this.data.to;
|
this.to = this.data.to;
|
||||||
this.call_hook = this.data.call_hook;
|
this.call_hook = this.data.call_hook;
|
||||||
this.timeout = this.data.timeout || 60;
|
this.timeout = this.data.timeout || 60;
|
||||||
this.sipRequestWithinDialogHook = this.data.sipRequestWithinDialogHook;
|
|
||||||
|
|
||||||
this.on('connect', this._onConnect.bind(this));
|
this.on('connect', this._onConnect.bind(this));
|
||||||
this.on('callStatus', this._onCallStatus.bind(this));
|
this.on('callStatus', this._onCallStatus.bind(this));
|
||||||
@@ -24,80 +21,34 @@ class TaskRestDial extends Task {
|
|||||||
|
|
||||||
get name() { return TaskName.RestDial; }
|
get name() { return TaskName.RestDial; }
|
||||||
|
|
||||||
set appJson(app_json) {
|
|
||||||
this.app_json = app_json;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* INVITE has just been sent at this point
|
* INVITE has just been sent at this point
|
||||||
*/
|
*/
|
||||||
async exec(cs) {
|
async exec(cs) {
|
||||||
await super.exec(cs);
|
await super.exec(cs);
|
||||||
this.cs = cs;
|
this.req = cs.req;
|
||||||
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();
|
this._setCallTimer();
|
||||||
await this.awaitTaskDone();
|
await this.awaitTaskDone();
|
||||||
}
|
}
|
||||||
|
|
||||||
turnOffAmd() {
|
|
||||||
if (this.callSession.ep && this.callSession.ep.amd) this.stopAmd(this.callSession.ep, this);
|
|
||||||
}
|
|
||||||
|
|
||||||
kill(cs) {
|
kill(cs) {
|
||||||
super.kill(cs);
|
super.kill(cs);
|
||||||
this._clearCallTimer();
|
this._clearCallTimer();
|
||||||
if (this.canCancel) {
|
if (this.req) {
|
||||||
this.canCancel = false;
|
this.req.cancel();
|
||||||
cs?.req?.cancel();
|
this.req = null;
|
||||||
}
|
}
|
||||||
this.notifyTaskDone();
|
this.notifyTaskDone();
|
||||||
}
|
}
|
||||||
|
|
||||||
async _onConnect(dlg) {
|
async _onConnect(dlg) {
|
||||||
this.canCancel = false;
|
this.req = null;
|
||||||
const cs = this.callSession;
|
const cs = this.callSession;
|
||||||
cs.setDialog(dlg);
|
cs.setDialog(dlg);
|
||||||
this.logger.debug('TaskRestDial:_onConnect - call connected');
|
|
||||||
if (this.sipRequestWithinDialogHook) this._initSipRequestWithinDialogHandler(cs, dlg);
|
|
||||||
try {
|
try {
|
||||||
const b3 = this.getTracingPropagation();
|
const tasks = await cs.requestor.request(this.call_hook, cs.callInfo);
|
||||||
const httpHeaders = b3 && {b3};
|
|
||||||
const params = {
|
|
||||||
...(cs.callInfo.toJSON()),
|
|
||||||
defaults: {
|
|
||||||
synthesizer: {
|
|
||||||
vendor: cs.speechSynthesisVendor,
|
|
||||||
language: cs.speechSynthesisLanguage,
|
|
||||||
voice: cs.speechSynthesisVoice
|
|
||||||
},
|
|
||||||
recognizer: {
|
|
||||||
vendor: cs.speechRecognizerVendor,
|
|
||||||
language: cs.speechRecognizerLanguage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let tasks;
|
|
||||||
if (this.app_json) {
|
|
||||||
this.logger.debug('TaskRestDial: using app_json from task data');
|
|
||||||
tasks = JSON.parse(this.app_json);
|
|
||||||
} else {
|
|
||||||
this.logger.debug({call_hook: this.call_hook}, 'TaskRestDial: retrieving application');
|
|
||||||
tasks = await cs.requestor.request('session:new', this.call_hook, params, httpHeaders);
|
|
||||||
}
|
|
||||||
if (tasks && Array.isArray(tasks)) {
|
if (tasks && Array.isArray(tasks)) {
|
||||||
this.logger.debug({tasks: tasks}, `TaskRestDial: replacing application with ${tasks.length} tasks`);
|
this.logger.debug({tasks: tasks}, `TaskRestDial: replacing application with ${tasks.length} tasks`);
|
||||||
cs.replaceApplication(normalizeJambones(this.logger, tasks).map((tdata) => makeTask(this.logger, tdata)));
|
cs.replaceApplication(normalizeJambones(this.logger, tasks).map((tdata) => makeTask(this.logger, tdata)));
|
||||||
@@ -111,7 +62,7 @@ class TaskRestDial extends Task {
|
|||||||
_onCallStatus(status) {
|
_onCallStatus(status) {
|
||||||
this.logger.debug(`CallStatus: ${status}`);
|
this.logger.debug(`CallStatus: ${status}`);
|
||||||
if (status >= 200) {
|
if (status >= 200) {
|
||||||
this.canCancel = false;
|
this.req = null;
|
||||||
this._clearCallTimer();
|
this._clearCallTimer();
|
||||||
if (status !== 200) this.notifyTaskDone();
|
if (status !== 200) this.notifyTaskDone();
|
||||||
}
|
}
|
||||||
@@ -129,29 +80,7 @@ class TaskRestDial extends Task {
|
|||||||
_onCallTimeout() {
|
_onCallTimeout() {
|
||||||
this.logger.debug('TaskRestDial: timeout expired without answer, killing task');
|
this.logger.debug('TaskRestDial: timeout expired without answer, killing task');
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
if (this.canCancel) {
|
this.kill();
|
||||||
this.canCancel = false;
|
|
||||||
this.cs?.req?.cancel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_initSipRequestWithinDialogHandler(cs, dlg) {
|
|
||||||
cs.sipRequestWithinDialogHook = this.sipRequestWithinDialogHook;
|
|
||||||
dlg.on('info', this._onRequestWithinDialog.bind(this, cs));
|
|
||||||
dlg.on('message', this._onRequestWithinDialog.bind(this, cs));
|
|
||||||
}
|
|
||||||
|
|
||||||
async _onRequestWithinDialog(cs, req, res) {
|
|
||||||
cs._onRequestWithinDialog(req, res);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class TaskSayLegacy extends Task {
|
|||||||
|
|
||||||
get name() { return TaskName.SayLegacy; }
|
get name() { return TaskName.SayLegacy; }
|
||||||
|
|
||||||
async exec(cs, {ep}) {
|
async exec(cs, ep) {
|
||||||
super.exec(cs);
|
super.exec(cs);
|
||||||
this.ep = ep;
|
this.ep = ep;
|
||||||
try {
|
try {
|
||||||
|
|||||||
256
lib/tasks/say.js
256
lib/tasks/say.js
@@ -1,250 +1,49 @@
|
|||||||
const Task = require('./task');
|
const Task = require('./task');
|
||||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
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 {
|
class TaskSay extends Task {
|
||||||
constructor(logger, opts, parentTask) {
|
constructor(logger, opts, parentTask) {
|
||||||
super(logger, opts);
|
super(logger, opts);
|
||||||
this.preconditions = TaskPreconditions.Endpoint;
|
this.preconditions = TaskPreconditions.Endpoint;
|
||||||
|
|
||||||
this.text = (Array.isArray(this.data.text) ? this.data.text : [this.data.text])
|
this.text = Array.isArray(this.data.text) ? this.data.text : [this.data.text];
|
||||||
.map((t) => breakLengthyTextIfNeeded(this.logger, t))
|
|
||||||
.flat();
|
|
||||||
|
|
||||||
this.loop = this.data.loop || 1;
|
this.loop = this.data.loop || 1;
|
||||||
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
|
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
|
||||||
this.synthesizer = this.data.synthesizer || {};
|
this.synthesizer = this.data.synthesizer || {};
|
||||||
this.disableTtsCache = this.data.disableTtsCache;
|
|
||||||
this.options = this.synthesizer.options || {};
|
|
||||||
this.isHandledByPrimaryProvider = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() { return TaskName.Say; }
|
get name() { return TaskName.Say; }
|
||||||
|
|
||||||
get summary() {
|
async exec(cs, ep) {
|
||||||
for (let i = 0; i < this.text.length; i++) {
|
|
||||||
if (this.text[i].startsWith('silence_stream')) continue;
|
|
||||||
return `${this.name}{text=${this.text[i].slice(0, 15)}${this.text[i].length > 15 ? '...' : ''}}`;
|
|
||||||
}
|
|
||||||
return `${this.name}{${this.text[0]}}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
_validateURL(urlString) {
|
|
||||||
try {
|
|
||||||
new URL(urlString);
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _synthesizeWithSpecificVendor(cs, ep, {vendor, language, voice, label}) {
|
|
||||||
const {srf} = cs;
|
const {srf} = cs;
|
||||||
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, srf);
|
|
||||||
const {writeAlerts, AlertType, stats} = srf.locals;
|
|
||||||
const {synthAudio} = srf.locals.dbHelpers;
|
const {synthAudio} = srf.locals.dbHelpers;
|
||||||
const engine = this.synthesizer.engine || 'standard';
|
|
||||||
const salt = cs.callSid;
|
|
||||||
|
|
||||||
let credentials = cs.getSpeechCredentials(vendor, 'tts', label);
|
|
||||||
/* 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];
|
|
||||||
}
|
|
||||||
} else if (vendor === 'deepgram') {
|
|
||||||
model = voice;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
} else if (vendor === 'elevenlabs') {
|
|
||||||
credentials = credentials || {};
|
|
||||||
credentials.model_id = this.options.model_id || credentials.model_id;
|
|
||||||
credentials.voice_settings = this.options.voice_settings || {};
|
|
||||||
credentials.optimize_streaming_latency = this.options.optimize_streaming_latency
|
|
||||||
|| credentials.optimize_streaming_latency;
|
|
||||||
voice = this.options.voice_id || voice;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.info({vendor, language, voice, model}, 'TaskSay:exec');
|
|
||||||
try {
|
|
||||||
if (!credentials) {
|
|
||||||
writeAlerts({
|
|
||||||
account_sid: cs.accountSid,
|
|
||||||
alert_type: AlertType.TTS_NOT_PROVISIONED,
|
|
||||||
vendor
|
|
||||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for no tts'));
|
|
||||||
this.notifyError({
|
|
||||||
msg: 'TTS error',
|
|
||||||
details:`No speech credentials provisioned for selected vendor ${vendor}`
|
|
||||||
});
|
|
||||||
throw new Error('no provisioned speech credentials for TTS');
|
|
||||||
}
|
|
||||||
// synthesize all of the text elements
|
|
||||||
let lastUpdated = false;
|
|
||||||
|
|
||||||
/* produce an audio segment from the provided text */
|
|
||||||
const generateAudio = async(text) => {
|
|
||||||
if (this.killed) return;
|
|
||||||
if (text.startsWith('silence_stream://')) return text;
|
|
||||||
|
|
||||||
/* otel: trace time for tts */
|
|
||||||
const {span} = this.startChildSpan('tts-generation', {
|
|
||||||
'tts.vendor': vendor,
|
|
||||||
'tts.language': language,
|
|
||||||
'tts.voice': voice
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const {filePath, servedFromCache, rtt} = await synthAudio(stats, {
|
|
||||||
account_sid: cs.accountSid,
|
|
||||||
text,
|
|
||||||
vendor,
|
|
||||||
language,
|
|
||||||
voice,
|
|
||||||
engine,
|
|
||||||
model,
|
|
||||||
salt,
|
|
||||||
credentials,
|
|
||||||
options: this.options,
|
|
||||||
disableTtsCache : this.disableTtsCache
|
|
||||||
});
|
|
||||||
this.logger.debug(`file ${filePath}, served from cache ${servedFromCache}`);
|
|
||||||
if (filePath) cs.trackTmpFile(filePath);
|
|
||||||
if (!servedFromCache && !lastUpdated) {
|
|
||||||
lastUpdated = true;
|
|
||||||
updateSpeechCredentialLastUsed(credentials.speech_credential_sid)
|
|
||||||
.catch(() => {/*already logged error */});
|
|
||||||
}
|
|
||||||
span.setAttributes({'tts.cached': servedFromCache});
|
|
||||||
span.end();
|
|
||||||
if (!servedFromCache && rtt) {
|
|
||||||
this.notifyStatus({
|
|
||||||
event: 'synthesized-audio',
|
|
||||||
vendor,
|
|
||||||
language,
|
|
||||||
characters: text.length,
|
|
||||||
elapsedTime: rtt
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return filePath;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info({err}, 'Error synthesizing tts');
|
|
||||||
span.end();
|
|
||||||
writeAlerts({
|
|
||||||
account_sid: cs.accountSid,
|
|
||||||
alert_type: AlertType.TTS_FAILURE,
|
|
||||||
vendor,
|
|
||||||
detail: err.message
|
|
||||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for tts failure'));
|
|
||||||
this.notifyError({msg: 'TTS error', details: err.message || err});
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const arr = this.text.map((t) => (this._validateURL(t) ? t : generateAudio(t)));
|
|
||||||
return (await Promise.all(arr)).filter((fp) => fp && fp.length);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info(err, 'TaskSay:exec error');
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
async exec(cs, {ep}) {
|
|
||||||
await super.exec(cs);
|
await super.exec(cs);
|
||||||
this.ep = ep;
|
this.ep = ep;
|
||||||
|
|
||||||
const vendor = this.synthesizer.vendor && this.synthesizer.vendor !== 'default' ?
|
|
||||||
this.synthesizer.vendor :
|
|
||||||
cs.speechSynthesisVendor;
|
|
||||||
const language = this.synthesizer.language && this.synthesizer.language !== 'default' ?
|
|
||||||
this.synthesizer.language :
|
|
||||||
cs.speechSynthesisLanguage ;
|
|
||||||
const voice = this.synthesizer.voice && this.synthesizer.voice !== 'default' ?
|
|
||||||
this.synthesizer.voice :
|
|
||||||
cs.speechSynthesisVoice;
|
|
||||||
const label = this.synthesizer.label && this.synthesizer.label !== 'default' ?
|
|
||||||
this.synthesizer.label :
|
|
||||||
cs.speechSynthesisLabel;
|
|
||||||
|
|
||||||
const fallbackVendor = this.synthesizer.fallbackVendor && this.synthesizer.fallbackVendor !== 'default' ?
|
|
||||||
this.synthesizer.fallbackVendor :
|
|
||||||
cs.fallbackSpeechSynthesisVendor;
|
|
||||||
const fallbackLanguage = this.synthesizer.fallbackLanguage && this.synthesizer.fallbackLanguage !== 'default' ?
|
|
||||||
this.synthesizer.fallbackLanguage :
|
|
||||||
cs.fallbackSpeechSynthesisLanguage ;
|
|
||||||
const fallbackVoice = this.synthesizer.fallbackVoice && this.synthesizer.fallbackVoice !== 'default' ?
|
|
||||||
this.synthesizer.fallbackVoice :
|
|
||||||
cs.fallbackSpeechSynthesisVoice;
|
|
||||||
const fallbackLabel = this.synthesizer.fallbackLabel && this.synthesizer.fallbackLabel !== 'default' ?
|
|
||||||
this.synthesizer.fallbackLabel :
|
|
||||||
cs.fallbackSpeechSynthesisLabel;
|
|
||||||
|
|
||||||
let filepath;
|
|
||||||
try {
|
try {
|
||||||
filepath = await this._synthesizeWithSpecificVendor(cs, ep, {vendor, language, voice, label});
|
// synthesize all of the text elements
|
||||||
} catch (error) {
|
const filepath = (await Promise.all(this.text.map(async(text) => {
|
||||||
if (fallbackVendor && this.isHandledByPrimaryProvider) {
|
const fp = await synthAudio({
|
||||||
this.isHandledByPrimaryProvider = false;
|
text,
|
||||||
this.logger.info(`Synthesize error, fallback to ${fallbackVendor}`);
|
vendor: this.synthesizer.vendor || cs.speechSynthesisVendor,
|
||||||
filepath = await this._synthesizeWithSpecificVendor(cs, ep,
|
language: this.synthesizer.language || cs.speechSynthesisLanguage,
|
||||||
{
|
voice: this.synthesizer.voice || cs.speechSynthesisVoice,
|
||||||
vendor: fallbackVendor,
|
salt: cs.callSid
|
||||||
language: fallbackLanguage,
|
}).catch((err) => this.logger.error(err, 'Error synthesizing text'));
|
||||||
voice: fallbackVoice,
|
if (fp) cs.trackTmpFile(fp);
|
||||||
label: fallbackLabel
|
return fp;
|
||||||
});
|
})))
|
||||||
} else {
|
.filter((fp) => fp && fp.length);
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.notifyStatus({event: 'start-playback'});
|
|
||||||
|
|
||||||
while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep?.connected) {
|
this.logger.debug({filepath}, 'synthesized files for tts');
|
||||||
let segment = 0;
|
|
||||||
while (!this.killed && segment < filepath.length) {
|
while (!this.killed && this.loop-- && this.ep.connected) {
|
||||||
if (cs.isInConference) {
|
let segment = 0;
|
||||||
const {memberId, confName, confUuid} = cs;
|
do {
|
||||||
await this.playToConfMember(this.ep, memberId, confName, confUuid, filepath[segment]);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.logger.debug(`Say:exec sending command to play file ${filepath[segment]}`);
|
|
||||||
await ep.play(filepath[segment]);
|
await ep.play(filepath[segment]);
|
||||||
this.logger.debug(`Say:exec completed play file ${filepath[segment]}`);
|
} while (!this.killed && ++segment < filepath.length);
|
||||||
}
|
|
||||||
segment++;
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.info(err, 'TaskSay:exec error');
|
||||||
}
|
}
|
||||||
this.emit('playDone');
|
this.emit('playDone');
|
||||||
}
|
}
|
||||||
@@ -253,14 +52,7 @@ class TaskSay extends Task {
|
|||||||
super.kill(cs);
|
super.kill(cs);
|
||||||
if (this.ep.connected) {
|
if (this.ep.connected) {
|
||||||
this.logger.debug('TaskSay:kill - killing audio');
|
this.logger.debug('TaskSay:kill - killing audio');
|
||||||
if (cs.isInConference) {
|
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||||
const {memberId, confName} = cs;
|
|
||||||
this.killPlayToConfMember(this.ep, memberId, confName);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.notifyStatus({event: 'kill-playback'});
|
|
||||||
this.ep.api('uuid_break', this.ep.uuid);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const Task = require('./task');
|
const Task = require('./task');
|
||||||
const {TaskName, TaskPreconditions, CallStatus} = require('../utils/constants');
|
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rejects an incoming call with user-specified status code and reason
|
* Rejects an incoming call with user-specified status code and reason
|
||||||
@@ -19,11 +19,6 @@ class TaskSipDecline extends Task {
|
|||||||
res.send(this.data.status, this.data.reason, {
|
res.send(this.data.status, this.data.reason, {
|
||||||
headers: this.headers
|
headers: this.headers
|
||||||
});
|
});
|
||||||
cs.emit('callStatusChange', {
|
|
||||||
callStatus: CallStatus.Failed,
|
|
||||||
sipStatus: this.data.status,
|
|
||||||
sipReason: this.data.reason
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
const Task = require('./task');
|
|
||||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
|
||||||
const {parseUri} = require('drachtio-srf');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* sends a sip REFER to transfer the existing call
|
|
||||||
*/
|
|
||||||
class TaskSipRefer extends Task {
|
|
||||||
constructor(logger, opts) {
|
|
||||||
super(logger, opts);
|
|
||||||
this.preconditions = TaskPreconditions.StableCall;
|
|
||||||
|
|
||||||
this.referTo = this.data.referTo;
|
|
||||||
this.referredBy = this.data.referredBy;
|
|
||||||
this.headers = this.data.headers || {};
|
|
||||||
this.eventHook = this.data.eventHook;
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() { return TaskName.SipRefer; }
|
|
||||||
|
|
||||||
async exec(cs) {
|
|
||||||
super.exec(cs);
|
|
||||||
const {dlg} = cs;
|
|
||||||
const {referTo, referredBy} = this._normalizeReferHeaders(cs, dlg);
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.notifyHandler = this._handleNotify.bind(this, cs, dlg);
|
|
||||||
dlg.on('notify', this.notifyHandler);
|
|
||||||
/* otel: trace time for tts */
|
|
||||||
this.referSpan = this.startSpan('send-refer', {
|
|
||||||
'refer.refer_to': referTo,
|
|
||||||
'refer.referred_by': referredBy
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await dlg.request({
|
|
||||||
method: 'REFER',
|
|
||||||
headers: {
|
|
||||||
...this.headers,
|
|
||||||
...(this.referToIsUri && {'X-Refer-To-Leave-Untouched': true}),
|
|
||||||
'Refer-To': referTo,
|
|
||||||
'Referred-By': referredBy
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.referStatus = response.status;
|
|
||||||
this.referSpan.setAttributes({'refer.status_code': response.status});
|
|
||||||
this.logger.info(`TaskSipRefer:exec - received ${this.referStatus} to REFER`);
|
|
||||||
|
|
||||||
/* if we fail, fall through to next verb. If success, we should get BYE from far end */
|
|
||||||
if (this.referStatus === 202) {
|
|
||||||
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});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info({err}, 'TaskSipRefer:exec - error sending REFER');
|
|
||||||
}
|
|
||||||
this.referSpan?.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
async kill(cs) {
|
|
||||||
super.kill(cs);
|
|
||||||
const {dlg} = cs;
|
|
||||||
dlg.off('notify', this.notifyHandler);
|
|
||||||
this.notifyTaskDone();
|
|
||||||
}
|
|
||||||
|
|
||||||
async _handleNotify(cs, dlg, req, res) {
|
|
||||||
res.send(200);
|
|
||||||
|
|
||||||
const contentType = req.get('Content-Type');
|
|
||||||
this.logger.debug({body: req.body}, `TaskSipRefer:_handleNotify got ${contentType}`);
|
|
||||||
|
|
||||||
if (contentType?.includes('message/sipfrag')) {
|
|
||||||
const arr = /SIP\/2\.0\s+(\d+)/.exec(req.body);
|
|
||||||
if (arr) {
|
|
||||||
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();
|
|
||||||
const httpHeaders = b3 && {b3};
|
|
||||||
await cs.requestor.request('verb:hook', this.eventHook,
|
|
||||||
{event: 'transfer-status', call_status: status}, httpHeaders);
|
|
||||||
}
|
|
||||||
if (status >= 200) {
|
|
||||||
this.referSpan.setAttributes({'refer.finalNotify': status});
|
|
||||||
await this.performAction({refer_status: 202, final_referred_call_status: status});
|
|
||||||
this.notifyTaskDone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_normalizeReferHeaders(cs, dlg) {
|
|
||||||
let {referTo, referredBy} = this;
|
|
||||||
|
|
||||||
/* get IP address of the SBC to use as hostname if needed */
|
|
||||||
const {host} = parseUri(dlg.remote.uri);
|
|
||||||
|
|
||||||
if (!referTo.startsWith('<') && !referTo.startsWith('sip') && !referTo.startsWith('"')) {
|
|
||||||
/* they may have only provided a phone number/user */
|
|
||||||
referTo = `sip:${referTo}@${host}`;
|
|
||||||
}
|
|
||||||
else this.referToIsUri = true;
|
|
||||||
if (!referredBy) {
|
|
||||||
/* default */
|
|
||||||
referredBy = cs.req?.callingNumber || dlg.local.uri;
|
|
||||||
this.logger.info({referredBy}, 'setting referredby');
|
|
||||||
}
|
|
||||||
if (!referredBy.startsWith('<') && !referredBy.startsWith('sip') && !referredBy.startsWith('"')) {
|
|
||||||
/* they may have only provided a phone number/user */
|
|
||||||
referredBy = `sip:${referredBy}@${host}`;
|
|
||||||
}
|
|
||||||
return {referTo, referredBy};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = TaskSipRefer;
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
const Task = require('./task');
|
|
||||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a SIP request (e.g. INFO, NOTIFY, etc) on an existing call leg
|
|
||||||
*/
|
|
||||||
class TaskSipRequest extends Task {
|
|
||||||
constructor(logger, opts) {
|
|
||||||
super(logger, opts);
|
|
||||||
this.preconditions = TaskPreconditions.StableCall;
|
|
||||||
|
|
||||||
this.method = this.data.method.toUpperCase();
|
|
||||||
this.headers = this.data.headers || {};
|
|
||||||
this.body = this.data.body;
|
|
||||||
if (this.body) this.body = `${this.body}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() { return TaskName.SipRequest; }
|
|
||||||
|
|
||||||
async exec(cs, {dlg}) {
|
|
||||||
super.exec(cs);
|
|
||||||
try {
|
|
||||||
this.logger.info({dlg}, `TaskSipRequest: sending a SIP ${this.method}`);
|
|
||||||
const res = await dlg.request({
|
|
||||||
method: this.method,
|
|
||||||
headers: this.headers,
|
|
||||||
body: this.body
|
|
||||||
});
|
|
||||||
const result = {result: 'success', sipStatus: res.status};
|
|
||||||
this.span.setAttributes({
|
|
||||||
...this.headers,
|
|
||||||
...(this.body && {body: this.body}),
|
|
||||||
'response.status_code': res.status
|
|
||||||
});
|
|
||||||
this.logger.debug({result}, `TaskSipRequest: received response to ${this.method}`);
|
|
||||||
await this.performAction(result);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error({err}, 'TaskSipRequest: error');
|
|
||||||
this.span.setAttributes({
|
|
||||||
...this.headers,
|
|
||||||
...(this.body && {body: this.body}),
|
|
||||||
'response.error': err.message
|
|
||||||
});
|
|
||||||
await this.performAction({result: 'failed', err: err.message});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = TaskSipRequest;
|
|
||||||
289
lib/tasks/specs.json
Normal file
289
lib/tasks/specs.json
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
{
|
||||||
|
"sip:decline": {
|
||||||
|
"properties": {
|
||||||
|
"status": "number",
|
||||||
|
"reason": "string",
|
||||||
|
"headers": "object"
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"earlyMedia": "boolean"
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"url"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"say": {
|
||||||
|
"properties": {
|
||||||
|
"text": "string|array",
|
||||||
|
"loop": "number",
|
||||||
|
"synthesizer": "#synthesizer",
|
||||||
|
"earlyMedia": "boolean"
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gather": {
|
||||||
|
"properties": {
|
||||||
|
"actionHook": "object|string",
|
||||||
|
"finishOnKey": "string",
|
||||||
|
"input": "array",
|
||||||
|
"numDigits": "number",
|
||||||
|
"partialResultHook": "object|string",
|
||||||
|
"speechTimeout": "number",
|
||||||
|
"timeout": "number",
|
||||||
|
"recognizer": "#recognizer",
|
||||||
|
"play": "#play",
|
||||||
|
"say": "#say"
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"actionHook"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"conference": {
|
||||||
|
"properties": {
|
||||||
|
"name": "string",
|
||||||
|
"beep": "boolean",
|
||||||
|
"startConferenceOnEnter": "boolean",
|
||||||
|
"endConferenceOnExit": "boolean",
|
||||||
|
"maxParticipants": "number",
|
||||||
|
"actionHook": "object|string",
|
||||||
|
"waitHook": "object|string",
|
||||||
|
"statusEvents": "array",
|
||||||
|
"statusHook": "object|string",
|
||||||
|
"enterHook": "object|string"
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dial": {
|
||||||
|
"properties": {
|
||||||
|
"actionHook": "object|string",
|
||||||
|
"answerOnBridge": "boolean",
|
||||||
|
"callerId": "string",
|
||||||
|
"confirmHook": "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",
|
||||||
|
"lang": "string",
|
||||||
|
"actionHook": "object|string",
|
||||||
|
"eventHook": "object|string",
|
||||||
|
"events": "[string]",
|
||||||
|
"welcomeEvent": "string",
|
||||||
|
"welcomeEventParams": "object",
|
||||||
|
"noInputTimeout": "number",
|
||||||
|
"noInputEvent": "string",
|
||||||
|
"passDtmfAsTextInput": "boolean",
|
||||||
|
"thinkingMusic": "string"
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"project",
|
||||||
|
"credentials",
|
||||||
|
"lang"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pause": {
|
||||||
|
"properties": {
|
||||||
|
"length": "number"
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"length"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"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": [
|
||||||
|
"transcriptionHook"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["phone", "sip", "user", "teams"]
|
||||||
|
},
|
||||||
|
"confirmHook": "object|string",
|
||||||
|
"method": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["GET", "POST"]
|
||||||
|
},
|
||||||
|
"name": "string",
|
||||||
|
"number": "string",
|
||||||
|
"sipUri": "string",
|
||||||
|
"auth": "#auth",
|
||||||
|
"vmail": "boolean",
|
||||||
|
"tenant": "string"
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"properties": {
|
||||||
|
"username": "string",
|
||||||
|
"password": "string"
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"username",
|
||||||
|
"password"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"synthesizer": {
|
||||||
|
"properties": {
|
||||||
|
"vendor": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["google", "aws", "polly"]
|
||||||
|
},
|
||||||
|
"language": "string",
|
||||||
|
"voice": "string",
|
||||||
|
"gender": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["MALE", "FEMALE", "NEUTRAL"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"vendor"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"recognizer": {
|
||||||
|
"properties": {
|
||||||
|
"vendor": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["google"]
|
||||||
|
},
|
||||||
|
"language": "string",
|
||||||
|
"hints": "array",
|
||||||
|
"profanityFilter": "boolean",
|
||||||
|
"interim": "boolean",
|
||||||
|
"dualChannel": "boolean"
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"vendor"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
const Task = require('./task');
|
|
||||||
const assert = require('assert');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const { TaskPreconditions, CobaltTranscriptionEvents } = require('../utils/constants');
|
|
||||||
|
|
||||||
class SttTask extends Task {
|
|
||||||
|
|
||||||
constructor(logger, data, parentTask) {
|
|
||||||
super(logger, data);
|
|
||||||
this.parentTask = parentTask;
|
|
||||||
|
|
||||||
this.preconditions = TaskPreconditions.Endpoint;
|
|
||||||
|
|
||||||
const {
|
|
||||||
setChannelVarsForStt,
|
|
||||||
normalizeTranscription,
|
|
||||||
setSpeechCredentialsAtRuntime,
|
|
||||||
compileSonioxTranscripts,
|
|
||||||
consolidateTranscripts
|
|
||||||
} = require('../utils/transcription-utils')(logger);
|
|
||||||
this.setChannelVarsForStt = setChannelVarsForStt;
|
|
||||||
this.normalizeTranscription = normalizeTranscription;
|
|
||||||
this.compileSonioxTranscripts = compileSonioxTranscripts;
|
|
||||||
this.consolidateTranscripts = consolidateTranscripts;
|
|
||||||
this.eventHandlers = [];
|
|
||||||
this.isHandledByPrimaryProvider = true;
|
|
||||||
if (this.data.recognizer) {
|
|
||||||
const recognizer = this.data.recognizer;
|
|
||||||
this.vendor = recognizer.vendor;
|
|
||||||
this.language = recognizer.language;
|
|
||||||
this.label = recognizer.label;
|
|
||||||
|
|
||||||
//fallback
|
|
||||||
this.fallbackVendor = recognizer.fallbackVendor || 'default';
|
|
||||||
this.fallbackLanguage = recognizer.fallbackLanguage || 'default';
|
|
||||||
this.fallbackLabel = recognizer.fallbackLabel || 'default';
|
|
||||||
|
|
||||||
/* let credentials be supplied in the recognizer object at runtime */
|
|
||||||
this.sttCredentials = setSpeechCredentialsAtRuntime(recognizer);
|
|
||||||
|
|
||||||
if (!Array.isArray(this.data.recognizer.altLanguages)) {
|
|
||||||
this.data.recognizer.altLanguages = [];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.data.recognizer = {hints: [], altLanguages: []};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* buffer for soniox transcripts */
|
|
||||||
this._sonioxTranscripts = [];
|
|
||||||
/*bug name prefix */
|
|
||||||
this.bugname_prefix = '';
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
async exec(cs, {ep, ep2}) {
|
|
||||||
super.exec(cs);
|
|
||||||
this.ep = ep;
|
|
||||||
this.ep2 = ep2;
|
|
||||||
// copy all value from config verb to this object.
|
|
||||||
if (cs.recognizer) {
|
|
||||||
for (const k in cs.recognizer) {
|
|
||||||
if (Array.isArray(this.data.recognizer[k]) ||
|
|
||||||
Array.isArray(cs.recognizer[k])) {
|
|
||||||
this.data.recognizer[k] = [
|
|
||||||
...this.data.recognizer[k],
|
|
||||||
...cs.recognizer[k]
|
|
||||||
];
|
|
||||||
} else if (typeof this.data.recognizer[k] === 'object' ||
|
|
||||||
typeof cs.recognizer[k] === 'object'
|
|
||||||
) {
|
|
||||||
this.data.recognizer[k] = {
|
|
||||||
...this.data.recognizer[k],
|
|
||||||
...cs.recognizer[k]
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
this.data.recognizer[k] = cs.recognizer[k] || this.data.recognizer[k];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 ('default' === this.label || !this.label) {
|
|
||||||
this.label = cs.speechRecognizerLabel;
|
|
||||||
if (this.data.recognizer) this.data.recognizer.label = this.label;
|
|
||||||
}
|
|
||||||
// Fallback options
|
|
||||||
if ('default' === this.fallbackVendor || !this.fallbackVendor) {
|
|
||||||
this.fallbackVendor = cs.fallbackSpeechRecognizerVendor;
|
|
||||||
if (this.data.recognizer) this.data.recognizer.fallbackVendor = this.fallbackVendor;
|
|
||||||
}
|
|
||||||
if ('default' === this.fallbackLanguage || !this.fallbackLanguage) {
|
|
||||||
this.fallbackLanguage = cs.fallbackSpeechRecognizerLanguage;
|
|
||||||
if (this.data.recognizer) this.data.recognizer.fallbackLanguage = this.fallbackLanguage;
|
|
||||||
}
|
|
||||||
if ('default' === this.fallbackLabel || !this.fallbackLabel) {
|
|
||||||
this.fallbackLabel = cs.fallbackSpeechRecognizerLabel;
|
|
||||||
if (this.data.recognizer) this.data.recognizer.fallbackLabel = this.fallbackLabel;
|
|
||||||
}
|
|
||||||
if (!this.data.recognizer.vendor) {
|
|
||||||
this.data.recognizer.vendor = this.vendor;
|
|
||||||
}
|
|
||||||
if (this.vendor === 'cobalt' && !this.data.recognizer.model) {
|
|
||||||
// By default, application saves cobalt model in language
|
|
||||||
this.data.recognizer.model = cs.speechRecognizerLanguage;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.sttCredentials) {
|
|
||||||
try {
|
|
||||||
this.sttCredentials = await this._initSpeechCredentials(this.cs, this.vendor, this.label);
|
|
||||||
} catch (error) {
|
|
||||||
if (this.fallbackVendor && this.isHandledByPrimaryProvider) {
|
|
||||||
await this._fallback();
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* when using cobalt model is required */
|
|
||||||
if (this.vendor === 'cobalt' && !this.data.recognizer.model) {
|
|
||||||
this.notifyError({ msg: 'ASR error', details:'Cobalt requires a model to be specified'});
|
|
||||||
throw new Error('Cobalt requires a model to be specified');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cs.hasAltLanguages) {
|
|
||||||
this.data.recognizer.altLanguages = this.data.recognizer.altLanguages.concat(cs.altLanguages);
|
|
||||||
this.logger.debug({altLanguages: this.altLanguages},
|
|
||||||
'STT:exec - applying altLanguages');
|
|
||||||
}
|
|
||||||
if (cs.hasGlobalSttPunctuation && !this.data.recognizer.punctuation) {
|
|
||||||
this.data.recognizer.punctuation = cs.globalSttPunctuation;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addCustomEventListener(ep, event, handler) {
|
|
||||||
this.eventHandlers.push({ep, event, handler});
|
|
||||||
ep.addCustomEventListener(event, handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeCustomEventListeners() {
|
|
||||||
this.eventHandlers.forEach((h) => h.ep.removeCustomEventListener(h.event, h.handler));
|
|
||||||
}
|
|
||||||
|
|
||||||
async _initSpeechCredentials(cs, vendor, label) {
|
|
||||||
const {getNuanceAccessToken, getIbmAccessToken} = cs.srf.locals.dbHelpers;
|
|
||||||
let credentials = cs.getSpeechCredentials(vendor, 'stt', label);
|
|
||||||
|
|
||||||
if (!credentials) {
|
|
||||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
|
||||||
this.logger.info(`ERROR stt using ${vendor} requested but creds not supplied`);
|
|
||||||
writeAlerts({
|
|
||||||
account_sid: cs.accountSid,
|
|
||||||
alert_type: AlertType.STT_NOT_PROVISIONED,
|
|
||||||
vendor
|
|
||||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for no stt'));
|
|
||||||
// Notify application that STT vender is wrong.
|
|
||||||
this.notifyError({
|
|
||||||
msg: 'ASR error',
|
|
||||||
details: `No speech-to-text service credentials for ${vendor} have been configured`
|
|
||||||
});
|
|
||||||
this.notifyTaskDone();
|
|
||||||
throw new Error(`No speech-to-text service credentials for ${vendor} have been configured`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vendor === 'nuance' && credentials.client_id) {
|
|
||||||
/* get nuance access token */
|
|
||||||
const {client_id, secret} = credentials;
|
|
||||||
const {access_token, servedFromCache} = await getNuanceAccessToken(client_id, secret, 'asr tts');
|
|
||||||
this.logger.debug({client_id}, `got nuance access token ${servedFromCache ? 'from cache' : ''}`);
|
|
||||||
credentials = {...credentials, access_token};
|
|
||||||
}
|
|
||||||
else if (vendor == 'ibm' && credentials.stt_api_key) {
|
|
||||||
/* get ibm access token */
|
|
||||||
const {stt_api_key, stt_region} = credentials;
|
|
||||||
const {access_token, servedFromCache} = await getIbmAccessToken(stt_api_key);
|
|
||||||
this.logger.debug({stt_api_key}, `got ibm access token ${servedFromCache ? 'from cache' : ''}`);
|
|
||||||
credentials = {...credentials, access_token, stt_region};
|
|
||||||
}
|
|
||||||
return credentials;
|
|
||||||
}
|
|
||||||
|
|
||||||
async _fallback() {
|
|
||||||
assert(this.fallbackVendor, 'fallback failed without fallbackVendor configuration');
|
|
||||||
this.isHandledByPrimaryProvider = false;
|
|
||||||
this.logger.info(`Failed to use primary STT provider, fallback to ${this.fallbackVendor}`);
|
|
||||||
this.vendor = this.fallbackVendor;
|
|
||||||
this.language = this.fallbackLanguage;
|
|
||||||
this.label = this.fallbackLabel;
|
|
||||||
this.data.recognizer.vendor = this.vendor;
|
|
||||||
this.data.recognizer.language = this.language;
|
|
||||||
this.data.recognizer.label = this.label;
|
|
||||||
this.sttCredentials = await this._initSpeechCredentials(this.cs, this.vendor, this.label);
|
|
||||||
}
|
|
||||||
|
|
||||||
async compileHintsForCobalt(ep, hostport, model, token, hints) {
|
|
||||||
const {retrieveKey} = this.cs.srf.locals.dbHelpers;
|
|
||||||
const hash = crypto.createHash('sha1');
|
|
||||||
hash.update(`${model}:${hints}`);
|
|
||||||
const key = `cobalt:${hash.digest('hex')}`;
|
|
||||||
this.context = await retrieveKey(key);
|
|
||||||
if (this.context) {
|
|
||||||
this.logger.debug({model, hints}, 'found cached cobalt context for supplied hints');
|
|
||||||
return this.context;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug({model, hints}, 'compiling cobalt context for supplied hints');
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.cobaltCompileResolver = resolve;
|
|
||||||
ep.addCustomEventListener(CobaltTranscriptionEvents.CompileContext, this._onCompileContext.bind(this, ep, key));
|
|
||||||
ep.api('uuid_cobalt_compile_context', [ep.uuid, hostport, model, token, hints], (err, evt) => {
|
|
||||||
if (err || 0 !== evt.getBody().indexOf('+OK')) {
|
|
||||||
ep.removeCustomEventListener(CobaltTranscriptionEvents.CompileContext);
|
|
||||||
return reject(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_onCompileContext(ep, key, evt) {
|
|
||||||
const {addKey} = this.cs.srf.locals.dbHelpers;
|
|
||||||
this.logger.debug({evt}, `received cobalt compile context event, will cache under ${key}`);
|
|
||||||
|
|
||||||
this.cobaltCompileResolver(evt.compiled_context);
|
|
||||||
ep.removeCustomEventListener(CobaltTranscriptionEvents.CompileContext);
|
|
||||||
this.cobaltCompileResolver = null;
|
|
||||||
|
|
||||||
//cache the compiled context
|
|
||||||
addKey(key, evt.compiled_context, 3600 * 12)
|
|
||||||
.catch((err) => this.logger.info({err}, `Error caching cobalt context for ${key}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
_doContinuousAsrWithDeepgram(asrTimeout) {
|
|
||||||
/* deepgram has an utterance_end_ms property that simplifies things */
|
|
||||||
assert(this.vendor === 'deepgram');
|
|
||||||
this.logger.debug(`_doContinuousAsrWithDeepgram - setting utterance_end_ms to ${asrTimeout}`);
|
|
||||||
const dgOptions = this.data.recognizer.deepgramOptions = this.data.recognizer.deepgramOptions || {};
|
|
||||||
dgOptions.utteranceEndMs = dgOptions.utteranceEndMs || asrTimeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
_onVendorConnect(_cs, _ep) {
|
|
||||||
this.logger.debug(`TaskGather:_on${this.vendor}Connect`);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onVendorError(cs, _ep, evt) {
|
|
||||||
this.logger.info({evt}, `${this.name}:_on${this.vendor}Error`);
|
|
||||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
|
||||||
writeAlerts({
|
|
||||||
account_sid: cs.accountSid,
|
|
||||||
alert_type: AlertType.STT_FAILURE,
|
|
||||||
message: 'STT failure reported by vendor',
|
|
||||||
detail: evt.error,
|
|
||||||
vendor: this.vendor,
|
|
||||||
}).catch((err) => this.logger.info({err}, `Error generating alert for ${this.vendor} connection failure`));
|
|
||||||
this.notifyError({msg: 'ASR error', details:`Failed connecting to speech vendor ${this.vendor}: ${evt.error}`});
|
|
||||||
}
|
|
||||||
|
|
||||||
_onVendorConnectFailure(cs, _ep, evt) {
|
|
||||||
const {reason} = evt;
|
|
||||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
|
||||||
this.logger.info({evt}, `${this.name}:_on${this.vendor}ConnectFailure`);
|
|
||||||
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 ${this.vendor} connection failure`));
|
|
||||||
this.notifyError({msg: 'ASR error', details:`Failed connecting to speech vendor ${this.vendor}: ${reason}`});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = SttTask;
|
|
||||||
@@ -12,7 +12,7 @@ class TaskTag extends Task {
|
|||||||
async exec(cs) {
|
async exec(cs) {
|
||||||
super.exec(cs);
|
super.exec(cs);
|
||||||
cs.callInfo.customerData = this.data;
|
cs.callInfo.customerData = this.data;
|
||||||
this.logger.debug({customerData: cs.callInfo.customerData}, 'TaskTag:exec set customer data in callInfo');
|
//this.logger.debug({callInfo: cs.callInfo.toJSON()}, 'TaskTag:exec set customer data in callInfo');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
const Emitter = require('events');
|
const Emitter = require('events');
|
||||||
const uuidv4 = require('uuid-random');
|
const uuidv4 = require('uuid/v4');
|
||||||
|
const debug = require('debug')('jambonz:feature-server');
|
||||||
|
const assert = require('assert');
|
||||||
const {TaskPreconditions} = require('../utils/constants');
|
const {TaskPreconditions} = require('../utils/constants');
|
||||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
const normalizeJambones = require('../utils/normalize-jambones');
|
||||||
const WsRequestor = require('../utils/ws-requestor');
|
const specs = new Map();
|
||||||
const {TaskName} = require('../utils/constants');
|
const _specData = require('./specs');
|
||||||
const {trace} = require('@opentelemetry/api');
|
for (const key in _specData) {specs.set(key, _specData[key]);}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @classdesc Represents a jambonz verb. This is a superclass that is extended
|
* @classdesc Represents a jambonz verb. This is a superclass that is extended
|
||||||
@@ -18,13 +20,9 @@ class Task extends Emitter {
|
|||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.data = data;
|
this.data = data;
|
||||||
this.actionHook = this.data.actionHook;
|
this.actionHook = this.data.actionHook;
|
||||||
this.id = data.id;
|
|
||||||
|
|
||||||
this._killInProgress = false;
|
this._killInProgress = false;
|
||||||
this._completionPromise = new Promise((resolve) => this._completionResolver = resolve);
|
this._completionPromise = new Promise((resolve) => this._completionResolver = resolve);
|
||||||
|
|
||||||
/* used when we play a prompt to a member in conference */
|
|
||||||
this._confPlayCompletionPromise = new Promise((resolve) => this._confPlayCompletionResolver = resolve);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,10 +39,6 @@ class Task extends Emitter {
|
|||||||
return this.cs;
|
return this.cs;
|
||||||
}
|
}
|
||||||
|
|
||||||
get summary() {
|
|
||||||
return this.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON() {
|
toJSON() {
|
||||||
return this.data;
|
return this.data;
|
||||||
}
|
}
|
||||||
@@ -65,37 +59,7 @@ class Task extends Emitter {
|
|||||||
kill(cs) {
|
kill(cs) {
|
||||||
if (this.cs && !this.cs.isConfirmCallSession) this.logger.debug(`${this.name} is being killed`);
|
if (this.cs && !this.cs.isConfirmCallSession) this.logger.debug(`${this.name} is being killed`);
|
||||||
this._killInProgress = true;
|
this._killInProgress = true;
|
||||||
|
// no-op
|
||||||
/* remove reference to parent task or else entangled parent-child tasks will not be gc'ed */
|
|
||||||
setImmediate(() => this.parentTask = null);
|
|
||||||
}
|
|
||||||
|
|
||||||
startSpan(name, attributes) {
|
|
||||||
const {srf} = require('../..');
|
|
||||||
const {tracer} = srf.locals.otel;
|
|
||||||
const span = tracer.startSpan(name, undefined, this.ctx);
|
|
||||||
if (attributes) span.setAttributes(attributes);
|
|
||||||
trace.setSpan(this.ctx, span);
|
|
||||||
return span;
|
|
||||||
}
|
|
||||||
|
|
||||||
startChildSpan(name, attributes) {
|
|
||||||
const {srf} = require('../..');
|
|
||||||
const {tracer} = srf.locals.otel;
|
|
||||||
const span = tracer.startSpan(name, undefined, this.ctx);
|
|
||||||
if (attributes) span.setAttributes(attributes);
|
|
||||||
const ctx = trace.setSpan(this.ctx, span);
|
|
||||||
return {span, ctx};
|
|
||||||
}
|
|
||||||
|
|
||||||
getTracingPropagation(encoding, span) {
|
|
||||||
// TODO: support encodings beyond b3 https://github.com/openzipkin/b3-propagation
|
|
||||||
if (span) {
|
|
||||||
return `${span.spanContext().traceId}-${span.spanContext().spanId}-1`;
|
|
||||||
}
|
|
||||||
if (this.span) {
|
|
||||||
return `${this.span.spanContext().traceId}-${this.span.spanContext().spanId}-1`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,21 +77,6 @@ class Task extends Emitter {
|
|||||||
return this._completionPromise;
|
return this._completionPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* when a play to conference member completes
|
|
||||||
*/
|
|
||||||
notifyConfPlayDone() {
|
|
||||||
this._confPlayCompletionResolver();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* when a subclass task has launched various async activities and is now simply waiting
|
|
||||||
* for them to complete it should call this method to block until that happens
|
|
||||||
*/
|
|
||||||
awaitConfPlayDone() {
|
|
||||||
return this._confPlayCompletionPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* provided as a convenience for tasks, this simply calls CallSession#normalizeUrl
|
* provided as a convenience for tasks, this simply calls CallSession#normalizeUrl
|
||||||
*/
|
*/
|
||||||
@@ -135,116 +84,18 @@ class Task extends Emitter {
|
|||||||
return this.callSession.normalizeUrl(url, method, auth);
|
return this.callSession.normalizeUrl(url, method, auth);
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyError(obj) {
|
|
||||||
if (this.cs.requestor instanceof WsRequestor) {
|
|
||||||
const params = {...obj, verb: this.name, id: this.id};
|
|
||||||
this.cs.requestor.request('jambonz:error', '/error', params)
|
|
||||||
.catch((err) => this.logger.info({err}, 'Task:notifyError error sending error'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
notifyStatus(obj) {
|
|
||||||
if (this.cs.notifyEvents && this.cs.requestor instanceof WsRequestor) {
|
|
||||||
const params = {...obj, verb: this.name, id: this.id};
|
|
||||||
this.cs.requestor.request('verb:status', '/status', params)
|
|
||||||
.catch((err) => this.logger.info({err}, 'Task:notifyStatus error sending error'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async performAction(results, expectResponse = true) {
|
async performAction(results, expectResponse = true) {
|
||||||
if (this.actionHook) {
|
if (this.actionHook) {
|
||||||
const type = this.name === TaskName.Redirect ? 'session:redirect' : 'verb:hook';
|
const params = results ? Object.assign(results, this.cs.callInfo.toJSON()) : this.cs.callInfo.toJSON();
|
||||||
const params = results ? Object.assign(this.cs.callInfo.toJSON(), results) : this.cs.callInfo.toJSON();
|
const json = await this.cs.requestor.request(this.actionHook, params);
|
||||||
const span = this.startSpan(`${type} (${this.actionHook})`);
|
if (expectResponse && json && Array.isArray(json)) {
|
||||||
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(type, this.actionHook, params, httpHeaders);
|
|
||||||
span.setAttributes({'http.statusCode': 200});
|
|
||||||
span.end();
|
|
||||||
if (expectResponse && json && Array.isArray(json)) {
|
|
||||||
const makeTask = require('./make_task');
|
|
||||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
|
||||||
if (tasks && tasks.length > 0) {
|
|
||||||
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
|
|
||||||
this.callSession.replaceApplication(tasks);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
span.setAttributes({'http.statusCode': err.statusCode});
|
|
||||||
span.end();
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async performHook(cs, hook, results) {
|
|
||||||
const 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(params)});
|
|
||||||
try {
|
|
||||||
const json = await cs.requestor.request('verb:hook', hook, params, httpHeaders);
|
|
||||||
span.setAttributes({'http.statusCode': 200});
|
|
||||||
span.end();
|
|
||||||
if (json && Array.isArray(json)) {
|
|
||||||
const makeTask = require('./make_task');
|
const makeTask = require('./make_task');
|
||||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||||
if (tasks && tasks.length > 0) {
|
if (tasks && tasks.length > 0) {
|
||||||
this.redirect(cs, tasks);
|
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
|
||||||
return true;
|
this.callSession.replaceApplication(tasks);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
} catch (err) {
|
|
||||||
span.setAttributes({'http.statusCode': err.statusCode});
|
|
||||||
span.end();
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
redirect(cs, tasks) {
|
|
||||||
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
|
|
||||||
this.isReplacingApplication = true;
|
|
||||||
cs.replaceApplication(tasks);
|
|
||||||
}
|
|
||||||
|
|
||||||
async playToConfMember(ep, memberId, confName, confUuid, filepath) {
|
|
||||||
try {
|
|
||||||
this.logger.debug(`Task:playToConfMember - playing ${filepath} to ${confName}:${memberId}`);
|
|
||||||
|
|
||||||
// listen for conference events
|
|
||||||
const handler = this.__onConferenceEvent.bind(this);
|
|
||||||
ep.conn.on('esl::event::CUSTOM::*', handler) ;
|
|
||||||
const response = await ep.api(`conference ${confName} play ${filepath} ${memberId}`);
|
|
||||||
this.logger.debug({response}, 'Task:playToConfMember - api call returned');
|
|
||||||
await this.awaitConfPlayDone();
|
|
||||||
ep.conn.removeListener('esl::event::CUSTOM::*', handler);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error({err}, `Task:playToConfMember - error playing ${filepath} to ${confName}:${memberId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async killPlayToConfMember(ep, memberId, confName) {
|
|
||||||
try {
|
|
||||||
this.logger.debug(`Task:killPlayToConfMember - killing audio to ${confName}:${memberId}`);
|
|
||||||
const response = await ep.api(`conference ${confName} stop ${memberId}`);
|
|
||||||
this.logger.debug({response}, 'Task:killPlayToConfMember - api call returned');
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error({err}, `Task:killPlayToConfMember - error killing audio to ${confName}:${memberId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
__onConferenceEvent(evt) {
|
|
||||||
const eventName = evt.getHeader('Event-Subclass') ;
|
|
||||||
if (eventName === 'conference::maintenance') {
|
|
||||||
const action = evt.getHeader('Action') ;
|
|
||||||
if (action === 'play-file-member-done') {
|
|
||||||
this.logger.debug('done playing file to conf member');
|
|
||||||
this.notifyConfPlayDone();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,12 +106,12 @@ class Task extends Emitter {
|
|||||||
delete obj.requestor;
|
delete obj.requestor;
|
||||||
delete obj.notifier;
|
delete obj.notifier;
|
||||||
obj.tasks = cs.getRemainingTaskData();
|
obj.tasks = cs.getRemainingTaskData();
|
||||||
if (opts && obj.tasks.length > 0) {
|
if (opts && obj.tasks.length > 1) {
|
||||||
const key = Object.keys(obj.tasks[0])[0];
|
const key = Object.keys(obj.tasks[0])[0];
|
||||||
Object.assign(obj.tasks[0][key], {_: opts});
|
Object.assign(obj.tasks[0][key], {_: opts});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug({obj}, 'Task:_doRefer - final object to store for receiving session on othe server');
|
this.logger.debug({obj}, 'Task:_doRefer');
|
||||||
|
|
||||||
const success = await addKey(uuid, JSON.stringify(obj), 30);
|
const success = await addKey(uuid, JSON.stringify(obj), 30);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
@@ -282,6 +133,73 @@ class Task extends Emitter {
|
|||||||
this.logger.error(err, 'Task:_doRefer error');
|
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;
|
module.exports = Task;
|
||||||
|
|||||||
@@ -1,438 +1,100 @@
|
|||||||
const assert = require('assert');
|
const Task = require('./task');
|
||||||
const {
|
const {TaskName, TaskPreconditions, TranscriptionEvents} = require('../utils/constants');
|
||||||
TaskName,
|
|
||||||
GoogleTranscriptionEvents,
|
|
||||||
NuanceTranscriptionEvents,
|
|
||||||
AwsTranscriptionEvents,
|
|
||||||
AzureTranscriptionEvents,
|
|
||||||
DeepgramTranscriptionEvents,
|
|
||||||
SonioxTranscriptionEvents,
|
|
||||||
CobaltTranscriptionEvents,
|
|
||||||
IbmTranscriptionEvents,
|
|
||||||
NvidiaTranscriptionEvents,
|
|
||||||
JambonzTranscriptionEvents,
|
|
||||||
TranscribeStatus,
|
|
||||||
AssemblyAiTranscriptionEvents
|
|
||||||
} = require('../utils/constants.json');
|
|
||||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
|
||||||
const SttTask = require('./stt-task');
|
|
||||||
|
|
||||||
const STT_LISTEN_SPAN_NAME = 'stt-listen';
|
class TaskTranscribe extends Task {
|
||||||
|
|
||||||
class TaskTranscribe extends SttTask {
|
|
||||||
constructor(logger, opts, parentTask) {
|
constructor(logger, opts, parentTask) {
|
||||||
super(logger, opts, parentTask);
|
super(logger, opts);
|
||||||
|
this.preconditions = TaskPreconditions.Endpoint;
|
||||||
|
|
||||||
this.transcriptionHook = this.data.transcriptionHook;
|
this.transcriptionHook = this.data.transcriptionHook;
|
||||||
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
|
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
|
||||||
|
|
||||||
if (this.data.recognizer) {
|
if (this.data.recognizer) {
|
||||||
this.interim = !!this.data.recognizer.interim;
|
this.language = this.data.recognizer.language || 'en-US';
|
||||||
this.separateRecognitionPerChannel = this.data.recognizer.separateRecognitionPerChannel;
|
this.vendor = this.data.recognizer.vendor;
|
||||||
|
this.interim = this.data.recognizer.interim === true;
|
||||||
|
this.dualChannel = this.data.recognizer.dualChannel === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.childSpan = [null, null];
|
|
||||||
|
|
||||||
// Continuous asr timeout
|
|
||||||
this.asrTimeout = typeof this.data.recognizer.asrTimeout === 'number' ? this.data.recognizer.asrTimeout * 1000 : 0;
|
|
||||||
if (this.asrTimeout > 0) {
|
|
||||||
this.isContinuousAsr = true;
|
|
||||||
}
|
|
||||||
/* buffer speech for continuous asr */
|
|
||||||
this._bufferedTranscripts = [];
|
|
||||||
this.bugname_prefix = 'transcribe_';
|
|
||||||
this.bugnames = new Map();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() { return TaskName.Transcribe; }
|
get name() { return TaskName.Transcribe; }
|
||||||
|
|
||||||
async exec(cs, {ep, ep2}) {
|
async exec(cs, ep, parentTask) {
|
||||||
await super.exec(cs, {ep, ep2});
|
super.exec(cs);
|
||||||
|
this.ep = ep;
|
||||||
if (this.data.recognizer.vendor === 'nuance') {
|
|
||||||
this.data.recognizer.nuanceOptions = {
|
|
||||||
// by default, nuance STT will recognize only 1st utterance.
|
|
||||||
// enable multiple allow nuance detact all utterances
|
|
||||||
utteranceDetectionMode: 'multiple',
|
|
||||||
...this.data.recognizer.nuanceOptions
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
|
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this._startTranscribing(cs, ep, 1);
|
await this._startTranscribing(ep);
|
||||||
if (this.separateRecognitionPerChannel && ep2) {
|
|
||||||
await this._startTranscribing(cs, ep2, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid)
|
|
||||||
.catch(() => {/*already logged error */});
|
|
||||||
|
|
||||||
await this.awaitTaskDone();
|
await this.awaitTaskDone();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.info(err, 'TaskTranscribe:exec - error');
|
this.logger.info(err, 'TaskTranscribe:exec - error');
|
||||||
this.parentTask && this.parentTask.emit('error', err);
|
|
||||||
}
|
}
|
||||||
this.removeCustomEventListeners();
|
ep.removeCustomEventListener(TranscriptionEvents.Transcription);
|
||||||
}
|
ep.removeCustomEventListener(TranscriptionEvents.NoAudioDetected);
|
||||||
|
ep.removeCustomEventListener(TranscriptionEvents.MaxDurationExceeded);
|
||||||
async _stopTranscription() {
|
|
||||||
let stopTranscription = false;
|
|
||||||
if (this.ep?.connected) {
|
|
||||||
stopTranscription = true;
|
|
||||||
this.ep.stopTranscription({
|
|
||||||
vendor: this.vendor,
|
|
||||||
bugname: this.bugnames.get(1)
|
|
||||||
})
|
|
||||||
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
|
||||||
}
|
|
||||||
if (this.separateRecognitionPerChannel && this.ep2 && this.ep2.connected) {
|
|
||||||
stopTranscription = true;
|
|
||||||
this.ep2.stopTranscription({vendor: this.vendor, bugname: this.bugnames.get(2)})
|
|
||||||
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return stopTranscription;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async kill(cs) {
|
async kill(cs) {
|
||||||
super.kill(cs);
|
super.kill(cs);
|
||||||
const stopTranscription = this._stopTranscription();
|
if (this.ep.connected) {
|
||||||
// hangup after 1 sec if we don't get a final transcription
|
this.ep.stopTranscription().catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
||||||
if (stopTranscription) this._timer = setTimeout(() => this.notifyTaskDone(), 1500);
|
|
||||||
else this.notifyTaskDone();
|
|
||||||
|
|
||||||
|
// hangup after 1 sec if we don't get a final transcription
|
||||||
|
this._timer = setTimeout(() => this.notifyTaskDone(), 1000);
|
||||||
|
}
|
||||||
|
else this.notifyTaskDone();
|
||||||
await this.awaitTaskDone();
|
await this.awaitTaskDone();
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateTranscribe(status) {
|
async _startTranscribing(ep) {
|
||||||
if (!this.killed && this.ep && this.ep.connected) {
|
const opts = {
|
||||||
this.logger.info(`TaskTranscribe:updateTranscribe status ${status}`);
|
GOOGLE_SPEECH_USE_ENHANCED: true,
|
||||||
switch (status) {
|
GOOGLE_SPEECH_MODEL: 'phone_call'
|
||||||
case TranscribeStatus.Pause:
|
};
|
||||||
await this._stopTranscription();
|
if (this.hints) {
|
||||||
break;
|
Object.assign(opts, {'GOOGLE_SPEECH_HINTS': this.hints.join(',')});
|
||||||
case TranscribeStatus.Resume:
|
|
||||||
await this._startTranscribing(this.cs, this.ep, 1);
|
|
||||||
if (this.separateRecognitionPerChannel && this.ep2) {
|
|
||||||
await this._startTranscribing(this.cs, this.ep2, 2);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
if (this.profanityFilter) {
|
||||||
|
Object.assign(opts, {'GOOGLE_SPEECH_PROFANITY_FILTER': true});
|
||||||
async _setSpeechHandlers(cs, ep, channel) {
|
|
||||||
if (this[`_speechHandlersSet_${channel}`]) return;
|
|
||||||
this[`_speechHandlersSet_${channel}`] = true;
|
|
||||||
let bugname;
|
|
||||||
|
|
||||||
/* some special deepgram logic */
|
|
||||||
if (this.vendor === 'deepgram') {
|
|
||||||
if (this.isContinuousAsr) this._doContinuousAsrWithDeepgram(this.asrTimeout);
|
|
||||||
}
|
}
|
||||||
|
if (this.dualChannel) {
|
||||||
const opts = this.setChannelVarsForStt(this, this.sttCredentials, this.data.recognizer);
|
Object.assign(opts, {'GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL': true});
|
||||||
switch (this.vendor) {
|
|
||||||
case 'google':
|
|
||||||
bugname = `${this.bugname_prefix}google_transcribe`;
|
|
||||||
this.addCustomEventListener(ep, GoogleTranscriptionEvents.Transcription,
|
|
||||||
this._onTranscription.bind(this, cs, ep, channel));
|
|
||||||
this.addCustomEventListener(ep, GoogleTranscriptionEvents.NoAudioDetected,
|
|
||||||
this._onNoAudio.bind(this, cs, ep, channel));
|
|
||||||
this.addCustomEventListener(ep, GoogleTranscriptionEvents.MaxDurationExceeded,
|
|
||||||
this._onMaxDurationExceeded.bind(this, cs, ep, channel));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'aws':
|
|
||||||
case 'polly':
|
|
||||||
bugname = `${this.bugname_prefix}aws_transcribe`;
|
|
||||||
this.addCustomEventListener(ep, AwsTranscriptionEvents.Transcription,
|
|
||||||
this._onTranscription.bind(this, cs, ep, channel));
|
|
||||||
this.addCustomEventListener(ep, AwsTranscriptionEvents.NoAudioDetected,
|
|
||||||
this._onNoAudio.bind(this, cs, ep, channel));
|
|
||||||
this.addCustomEventListener(ep, AwsTranscriptionEvents.MaxDurationExceeded,
|
|
||||||
this._onMaxDurationExceeded.bind(this, cs, ep, channel));
|
|
||||||
break;
|
|
||||||
case 'microsoft':
|
|
||||||
bugname = `${this.bugname_prefix}azure_transcribe`;
|
|
||||||
this.addCustomEventListener(ep, AzureTranscriptionEvents.Transcription,
|
|
||||||
this._onTranscription.bind(this, cs, ep, channel));
|
|
||||||
this.addCustomEventListener(ep, AzureTranscriptionEvents.NoSpeechDetected,
|
|
||||||
this._onNoAudio.bind(this, cs, ep, channel));
|
|
||||||
break;
|
|
||||||
case 'nuance':
|
|
||||||
bugname = `${this.bugname_prefix}nuance_transcribe`;
|
|
||||||
this.addCustomEventListener(ep, NuanceTranscriptionEvents.Transcription,
|
|
||||||
this._onTranscription.bind(this, cs, ep, channel));
|
|
||||||
break;
|
|
||||||
case 'deepgram':
|
|
||||||
bugname = `${this.bugname_prefix}deepgram_transcribe`;
|
|
||||||
this.addCustomEventListener(ep, DeepgramTranscriptionEvents.Transcription,
|
|
||||||
this._onTranscription.bind(this, cs, ep, channel));
|
|
||||||
this.addCustomEventListener(ep, DeepgramTranscriptionEvents.Connect,
|
|
||||||
this._onVendorConnect.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, DeepgramTranscriptionEvents.ConnectFailure,
|
|
||||||
this._onVendorConnectFailure.bind(this, cs, ep, channel));
|
|
||||||
|
|
||||||
/* if app sets deepgramOptions.utteranceEndMs they essentially want continuous asr */
|
|
||||||
if (opts.DEEPGRAM_SPEECH_UTTERANCE_END_MS) this.isContinuousAsr = true;
|
|
||||||
|
|
||||||
break;
|
|
||||||
case 'soniox':
|
|
||||||
bugname = `${this.bugname_prefix}soniox_transcribe`;
|
|
||||||
this.addCustomEventListener(ep, SonioxTranscriptionEvents.Transcription,
|
|
||||||
this._onTranscription.bind(this, cs, ep, channel));
|
|
||||||
break;
|
|
||||||
case 'cobalt':
|
|
||||||
bugname = `${this.bugname_prefix}cobalt_transcribe`;
|
|
||||||
this.addCustomEventListener(ep, CobaltTranscriptionEvents.Transcription,
|
|
||||||
this._onTranscription.bind(this, cs, ep, channel));
|
|
||||||
|
|
||||||
/* cobalt doesnt have language, it has model, which is required */
|
|
||||||
if (!this.data.recognizer.model) {
|
|
||||||
throw new Error('Cobalt requires a model to be specified');
|
|
||||||
}
|
|
||||||
this.language = this.data.recognizer.model;
|
|
||||||
|
|
||||||
/* special case: if using hints with cobalt we need to compile them */
|
|
||||||
this.hostport = opts.COBALT_SERVER_URI;
|
|
||||||
if (this.vendor === 'cobalt' && opts.COBALT_SPEECH_HINTS) {
|
|
||||||
try {
|
|
||||||
const context = await this.compileHintsForCobalt(
|
|
||||||
ep,
|
|
||||||
opts.COBALT_SERVER_URI,
|
|
||||||
this.data.recognizer.model,
|
|
||||||
opts.COBALT_CONTEXT_TOKEN,
|
|
||||||
opts.COBALT_SPEECH_HINTS
|
|
||||||
);
|
|
||||||
if (context) opts.COBALT_COMPILED_CONTEXT_DATA = context;
|
|
||||||
delete opts.COBALT_SPEECH_HINTS;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error({err}, 'Error compiling hints for cobalt');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'ibm':
|
|
||||||
bugname = `${this.bugname_prefix}ibm_transcribe`;
|
|
||||||
this.addCustomEventListener(ep, IbmTranscriptionEvents.Transcription,
|
|
||||||
this._onTranscription.bind(this, cs, ep, channel));
|
|
||||||
this.addCustomEventListener(ep, IbmTranscriptionEvents.Connect,
|
|
||||||
this._onVendorConnect.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, IbmTranscriptionEvents.ConnectFailure,
|
|
||||||
this._onVendorConnectFailure.bind(this, cs, ep, channel));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'nvidia':
|
|
||||||
bugname = `${this.bugname_prefix}nvidia_transcribe`;
|
|
||||||
this.addCustomEventListener(ep, NvidiaTranscriptionEvents.Transcription,
|
|
||||||
this._onTranscription.bind(this, cs, ep, channel));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'assemblyai':
|
|
||||||
bugname = `${this.bugname_prefix}assemblyai_transcribe`;
|
|
||||||
this.addCustomEventListener(ep, AssemblyAiTranscriptionEvents.Transcription,
|
|
||||||
this._onTranscription.bind(this, cs, ep, channel));
|
|
||||||
this.addCustomEventListener(ep,
|
|
||||||
AssemblyAiTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, AssemblyAiTranscriptionEvents.Error, this._onVendorError.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, AssemblyAiTranscriptionEvents.ConnectFailure,
|
|
||||||
this._onVendorConnectFailure.bind(this, cs, ep, channel));
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
if (this.vendor.startsWith('custom:')) {
|
|
||||||
bugname = `${this.bugname_prefix}${this.vendor}_transcribe`;
|
|
||||||
this.addCustomEventListener(ep, JambonzTranscriptionEvents.Transcription,
|
|
||||||
this._onTranscription.bind(this, cs, ep, channel));
|
|
||||||
this.addCustomEventListener(ep, JambonzTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
|
|
||||||
this.addCustomEventListener(ep, JambonzTranscriptionEvents.ConnectFailure,
|
|
||||||
this._onVendorConnectFailure.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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// save dedicated bugname for each endpoint
|
|
||||||
this.bugnames.set(channel, `${bugname}_${Date.now()}`);
|
|
||||||
/* common handler for all stt engine errors */
|
|
||||||
this.addCustomEventListener(ep, JambonzTranscriptionEvents.Error, this._onJambonzError.bind(this, cs, ep, channel));
|
|
||||||
await ep.set(opts)
|
await ep.set(opts)
|
||||||
.catch((err) => this.logger.info(err, 'Error setting channel variables'));
|
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing'));
|
||||||
|
|
||||||
|
ep.addCustomEventListener(TranscriptionEvents.Transcription, this._onTranscription.bind(this, ep));
|
||||||
|
ep.addCustomEventListener(TranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, ep));
|
||||||
|
ep.addCustomEventListener(TranscriptionEvents.MaxDurationExceeded, this._onMaxDurationExceeded.bind(this, ep));
|
||||||
|
|
||||||
|
await this._transcribe(ep);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _startTranscribing(cs, ep, channel) {
|
async _transcribe(ep) {
|
||||||
await this._setSpeechHandlers(cs, ep, channel);
|
await this.ep.startTranscription({
|
||||||
await this._transcribe(ep, channel);
|
|
||||||
|
|
||||||
/* start child span for this channel */
|
|
||||||
const {span, ctx} = this.startChildSpan(`${STT_LISTEN_SPAN_NAME}:${channel}`);
|
|
||||||
this.childSpan[channel - 1] = {span, ctx};
|
|
||||||
}
|
|
||||||
|
|
||||||
async _transcribe(ep, channel) {
|
|
||||||
this.logger.debug(
|
|
||||||
`TaskTranscribe:_transcribe - starting transcription vendor
|
|
||||||
${this.vendor} bugname ${this.bugnames.get(channel)}`);
|
|
||||||
await ep.startTranscription({
|
|
||||||
vendor: this.vendor,
|
|
||||||
interim: this.interim ? true : false,
|
interim: this.interim ? true : false,
|
||||||
locale: this.language,
|
language: this.language || this.callSession.speechRecognizerLanguage,
|
||||||
channels: /*this.separateRecognitionPerChannel ? 2 : */ 1,
|
channels: this.dualChannel ? 2 : 1
|
||||||
bugname: this.bugnames.get(channel),
|
|
||||||
hostport: this.hostport
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async _onTranscription(cs, ep, channel, evt, fsEvent) {
|
_onTranscription(ep, evt) {
|
||||||
// make sure this is not a transcript from answering machine detection
|
this.logger.debug(evt, 'TaskTranscribe:_onTranscription');
|
||||||
const bugname = fsEvent.getHeader('media-bugname');
|
this.cs.requestor.request(this.transcriptionHook, Object.assign({speech: evt}, this.cs.callInfo))
|
||||||
if (bugname && this.bugnames.get(channel) !== bugname) return;
|
.catch((err) => this.logger.info(err, 'TranscribeTask:_onTranscription error'));
|
||||||
|
|
||||||
if (this.vendor === 'ibm' && evt?.state === 'listening') return;
|
|
||||||
|
|
||||||
if (this.vendor === 'deepgram' && evt.type === 'UtteranceEnd') {
|
|
||||||
/* we will only get this when we have set utterance_end_ms */
|
|
||||||
if (this._bufferedTranscripts.length === 0) {
|
|
||||||
this.logger.debug('Gather:_onTranscription - got UtteranceEnd event from deepgram but no buffered transcripts');
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.logger.debug('Gather:_onTranscription - got UtteranceEnd event from deepgram, return buffered transcript');
|
|
||||||
evt = this.consolidateTranscripts(this._bufferedTranscripts, 1, this.language);
|
|
||||||
this._bufferedTranscripts = [];
|
|
||||||
this._resolve('speech', evt);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.logger.debug({evt}, 'TaskTranscribe:_onTranscription - before normalization');
|
|
||||||
|
|
||||||
evt = this.normalizeTranscription(evt, this.vendor, channel, this.language, undefined,
|
|
||||||
this.data.recognizer.punctuation);
|
|
||||||
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) {
|
|
||||||
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, channel);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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.isContinuousAsr && evt.is_final) {
|
|
||||||
this._bufferedTranscripts.push(evt);
|
|
||||||
this._startAsrTimer(channel);
|
|
||||||
} else {
|
|
||||||
await this._resolve(channel, evt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _resolve(channel, evt) {
|
|
||||||
/* we've got a transcript, so end the otel child span for this channel */
|
|
||||||
if (this.childSpan[channel - 1] && this.childSpan[channel - 1].span) {
|
|
||||||
this.childSpan[channel - 1].span.setAttributes({
|
|
||||||
channel,
|
|
||||||
'stt.resolve': 'transcript',
|
|
||||||
'stt.result': JSON.stringify(evt)
|
|
||||||
});
|
|
||||||
this.childSpan[channel - 1].span.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.transcriptionHook) {
|
|
||||||
const b3 = this.getTracingPropagation();
|
|
||||||
const httpHeaders = b3 && {b3};
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
if (this.killed) {
|
if (this.killed) {
|
||||||
this.logger.debug('TaskTranscribe:_onTranscription exiting after receiving final transcription');
|
this.logger.debug('TaskTranscribe:_onTranscription exiting after receiving final transcription');
|
||||||
this._clearTimer();
|
this._clearTimer();
|
||||||
this.notifyTaskDone();
|
this.notifyTaskDone();
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
/* start another child span for this channel */
|
|
||||||
const {span, ctx} = this.startChildSpan(`${STT_LISTEN_SPAN_NAME}:${channel}`);
|
|
||||||
this.childSpan[channel - 1] = {span, ctx};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_onNoAudio(cs, ep, channel) {
|
_onNoAudio(ep) {
|
||||||
this.logger.debug(`TaskTranscribe:_onNoAudio restarting transcription on channel ${channel}`);
|
this.logger.debug('TaskTranscribe:_onNoAudio restarting transcription');
|
||||||
if (this.childSpan[channel - 1] && this.childSpan[channel - 1].span) {
|
this._transcribe(ep);
|
||||||
this.childSpan[channel - 1].span.setAttributes({
|
|
||||||
channel,
|
|
||||||
'stt.resolve': 'timeout'
|
|
||||||
});
|
|
||||||
this.childSpan[channel - 1].span.end();
|
|
||||||
}
|
|
||||||
this._transcribe(ep, channel);
|
|
||||||
|
|
||||||
/* start new child span for this channel */
|
|
||||||
const {span, ctx} = this.startChildSpan(`${STT_LISTEN_SPAN_NAME}:${channel}`);
|
|
||||||
this.childSpan[channel - 1] = {span, ctx};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_onMaxDurationExceeded(cs, ep, channel) {
|
_onMaxDurationExceeded(ep) {
|
||||||
this.logger.debug(`TaskTranscribe:_onMaxDurationExceeded restarting transcription on channel ${channel}`);
|
this.logger.debug('TaskTranscribe:_onMaxDurationExceeded restarting transcription');
|
||||||
if (this.childSpan[channel - 1] && this.childSpan[channel - 1].span) {
|
this._transcribe(ep);
|
||||||
this.childSpan[channel - 1].span.setAttributes({
|
|
||||||
channel,
|
|
||||||
'stt.resolve': 'max duration exceeded'
|
|
||||||
});
|
|
||||||
this.childSpan[channel - 1].span.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
this._transcribe(ep, channel);
|
|
||||||
|
|
||||||
/* start new child span for this channel */
|
|
||||||
const {span, ctx} = this.startChildSpan(`${STT_LISTEN_SPAN_NAME}:${channel}`);
|
|
||||||
this.childSpan[channel - 1] = {span, ctx};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_clearTimer() {
|
_clearTimer() {
|
||||||
@@ -441,76 +103,6 @@ class TaskTranscribe extends SttTask {
|
|||||||
this._timer = null;
|
this._timer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _onJambonzError(cs, _ep, channel, evt) {
|
|
||||||
this.logger.info({evt}, 'TaskTranscribe:_onJambonzError');
|
|
||||||
if (this.isHandledByPrimaryProvider && this.fallbackVendor) {
|
|
||||||
_ep.stopTranscription({
|
|
||||||
vendor: this.vendor,
|
|
||||||
bugname: this.bugnames.get(channel)
|
|
||||||
})
|
|
||||||
.catch((err) => this.logger.error({err}, `Error stopping transcription for primary vendor ${this.vendor}`));
|
|
||||||
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
|
|
||||||
try {
|
|
||||||
await this._fallback();
|
|
||||||
let channel = 1;
|
|
||||||
if (this.ep !== _ep) {
|
|
||||||
channel = 2;
|
|
||||||
}
|
|
||||||
this._startTranscribing(cs, _ep, channel);
|
|
||||||
updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid);
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.info({error}, `There is error while falling back to ${this.fallbackVendor}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
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}`});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_onVendorConnectFailure(cs, _ep, channel, evt) {
|
|
||||||
super._onVendorConnectFailure(cs, _ep, evt);
|
|
||||||
if (this.childSpan[channel - 1] && this.childSpan[channel - 1].span) {
|
|
||||||
this.childSpan[channel - 1].span.setAttributes({
|
|
||||||
channel,
|
|
||||||
'stt.resolve': 'connection failure'
|
|
||||||
});
|
|
||||||
this.childSpan[channel - 1].span.end();
|
|
||||||
}
|
|
||||||
this.notifyTaskDone();
|
|
||||||
}
|
|
||||||
|
|
||||||
_startAsrTimer(channel) {
|
|
||||||
if (this.vendor === 'deepgram') return; // no need
|
|
||||||
assert(this.isContinuousAsr);
|
|
||||||
this._clearAsrTimer(channel);
|
|
||||||
this._asrTimer = setTimeout(() => {
|
|
||||||
this.logger.debug(`TaskTranscribe:_startAsrTimer - asr timer went off for channel: ${channel}`);
|
|
||||||
const evt = this.consolidateTranscripts(this._bufferedTranscripts, channel, this.language);
|
|
||||||
this._bufferedTranscripts = [];
|
|
||||||
this._resolve(channel, evt);
|
|
||||||
}, this.asrTimeout);
|
|
||||||
this.logger.debug(`TaskTranscribe:_startAsrTimer: set for ${this.asrTimeout}ms for channel ${channel}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
_clearAsrTimer(channel) {
|
|
||||||
if (this._asrTimer) clearTimeout(this._asrTimer);
|
|
||||||
this._asrTimer = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = TaskTranscribe;
|
module.exports = TaskTranscribe;
|
||||||
|
|||||||
@@ -1,415 +0,0 @@
|
|||||||
const Emitter = require('events');
|
|
||||||
const {readFile} = require('fs');
|
|
||||||
const {
|
|
||||||
TaskName,
|
|
||||||
GoogleTranscriptionEvents,
|
|
||||||
AwsTranscriptionEvents,
|
|
||||||
AzureTranscriptionEvents,
|
|
||||||
NuanceTranscriptionEvents,
|
|
||||||
NvidiaTranscriptionEvents,
|
|
||||||
IbmTranscriptionEvents,
|
|
||||||
SonioxTranscriptionEvents,
|
|
||||||
CobaltTranscriptionEvents,
|
|
||||||
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',
|
|
||||||
opts.recognizer?.label || cs.speechRecognizerLabel);
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
case 'cobalt':
|
|
||||||
ep.addCustomEventListener(CobaltTranscriptionEvents.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,56 +1,26 @@
|
|||||||
const Emitter = require('events');
|
const Emitter = require('events');
|
||||||
const bent = require('bent');
|
const bent = require('bent');
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
const {
|
const PORT = process.env.AWS_SNS_PORT || 3001;
|
||||||
AWS_REGION,
|
|
||||||
AWS_SNS_PORT: PORT,
|
|
||||||
AWS_SNS_TOPIC_ARM,
|
|
||||||
AWS_SNS_PORT_MAX,
|
|
||||||
} = require('../config');
|
|
||||||
const {LifeCycleEvents} = require('./constants');
|
const {LifeCycleEvents} = require('./constants');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const app = express();
|
const app = express();
|
||||||
const getString = bent('string');
|
const getString = bent('string');
|
||||||
const {
|
const AWS = require('aws-sdk');
|
||||||
SNSClient,
|
const sns = new AWS.SNS({apiVersion: '2010-03-31'});
|
||||||
SubscribeCommand,
|
const autoscaling = new AWS.AutoScaling({apiVersion: '2011-01-01'});
|
||||||
UnsubscribeCommand } = require('@aws-sdk/client-sns');
|
|
||||||
const snsClient = new SNSClient({ region: AWS_REGION, apiVersion: '2010-03-31' });
|
|
||||||
const {
|
|
||||||
AutoScalingClient,
|
|
||||||
DescribeAutoScalingGroupsCommand,
|
|
||||||
CompleteLifecycleActionCommand } = require('@aws-sdk/client-auto-scaling');
|
|
||||||
const autoScalingClient = new AutoScalingClient({ region: AWS_REGION, apiVersion: '2011-01-01' });
|
|
||||||
const {Parser} = require('xml2js');
|
const {Parser} = require('xml2js');
|
||||||
const parser = new Parser();
|
const parser = new Parser();
|
||||||
const {validatePayload} = require('verify-aws-sns-signature');
|
const {validatePayload} = require('verify-aws-sns-signature');
|
||||||
|
|
||||||
|
AWS.config.update({region: process.env.AWS_REGION});
|
||||||
|
|
||||||
class SnsNotifier extends Emitter {
|
class SnsNotifier extends Emitter {
|
||||||
constructor(logger) {
|
constructor(logger) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
_doListen(logger, app, port, resolve) {
|
|
||||||
return app.listen(port, () => {
|
|
||||||
this.snsEndpoint = `http://${this.publicIp}:${port}`;
|
|
||||||
logger.info(`SNS lifecycle server listening on http://localhost:${port}`);
|
|
||||||
resolve(app);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_handleErrors(logger, app, resolve, reject, e) {
|
|
||||||
if (e.code === 'EADDRINUSE' &&
|
|
||||||
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) {
|
async _handlePost(req, res) {
|
||||||
try {
|
try {
|
||||||
@@ -74,8 +44,7 @@ class SnsNotifier extends Emitter {
|
|||||||
subscriptionRequestId: this.subscriptionRequestId
|
subscriptionRequestId: this.subscriptionRequestId
|
||||||
}, 'response from SNS SubscribeURL');
|
}, 'response from SNS SubscribeURL');
|
||||||
const data = await this.describeInstance();
|
const data = await this.describeInstance();
|
||||||
this.lifecycleState = data.AutoScalingGroups[0].Instances[0].LifecycleState;
|
this.lifecycleState = data.AutoScalingInstances[0].LifecycleState;
|
||||||
this.emit('SubscriptionConfirmation', {publicIp: this.publicIp});
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'Notification':
|
case 'Notification':
|
||||||
@@ -114,9 +83,11 @@ class SnsNotifier extends Emitter {
|
|||||||
this.logger.debug('SnsNotifier: retrieving instance data');
|
this.logger.debug('SnsNotifier: retrieving instance data');
|
||||||
this.instanceId = await getString('http://169.254.169.254/latest/meta-data/instance-id');
|
this.instanceId = await getString('http://169.254.169.254/latest/meta-data/instance-id');
|
||||||
this.publicIp = await getString('http://169.254.169.254/latest/meta-data/public-ipv4');
|
this.publicIp = await getString('http://169.254.169.254/latest/meta-data/public-ipv4');
|
||||||
|
this.snsEndpoint = `http://${this.publicIp}:${PORT}`;
|
||||||
this.logger.info({
|
this.logger.info({
|
||||||
instanceId: this.instanceId,
|
instanceId: this.instanceId,
|
||||||
publicIp: this.publicIp
|
publicIp: this.publicIp,
|
||||||
|
snsEndpoint: this.snsEndpoint
|
||||||
}, 'retrieved AWS instance data');
|
}, 'retrieved AWS instance data');
|
||||||
|
|
||||||
// start listening
|
// start listening
|
||||||
@@ -128,10 +99,7 @@ class SnsNotifier extends Emitter {
|
|||||||
this.logger.error(err, 'burped error');
|
this.logger.error(err, 'burped error');
|
||||||
res.status(err.status || 500).json({msg: err.message});
|
res.status(err.status || 500).json({msg: err.message});
|
||||||
});
|
});
|
||||||
return new Promise((resolve, reject) => {
|
app.listen(PORT);
|
||||||
const server = this._doListen(this.logger, app, PORT, resolve);
|
|
||||||
server.on('error', this._handleErrors.bind(this, this.logger, app, resolve, reject));
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error({err}, 'Error retrieving AWS instance metadata');
|
this.logger.error({err}, 'Error retrieving AWS instance metadata');
|
||||||
@@ -140,56 +108,51 @@ class SnsNotifier extends Emitter {
|
|||||||
|
|
||||||
async subscribe() {
|
async subscribe() {
|
||||||
try {
|
try {
|
||||||
const params = {
|
const response = await sns.subscribe({
|
||||||
Protocol: 'http',
|
Protocol: 'http',
|
||||||
TopicArn: AWS_SNS_TOPIC_ARM,
|
TopicArn: process.env.AWS_SNS_TOPIC_ARM,
|
||||||
Endpoint: this.snsEndpoint
|
Endpoint: this.snsEndpoint
|
||||||
};
|
}).promise();
|
||||||
const response = await snsClient.send(new SubscribeCommand(params));
|
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) {
|
} catch (err) {
|
||||||
this.logger.error({err}, `Error subscribing to SNS topic arn ${AWS_SNS_TOPIC_ARM}`);
|
this.logger.error({err}, `Error subscribing to SNS topic arn ${process.env.AWS_SNS_TOPIC_ARM}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async unsubscribe() {
|
async unsubscribe() {
|
||||||
if (!this.subscriptionArn) throw new Error('SnsNotifier#unsubscribe called without an active subscription');
|
if (!this.subscriptionArn) throw new Error('SnsNotifier#unsubscribe called without an active subscription');
|
||||||
try {
|
try {
|
||||||
const params = {
|
const response = await sns.unsubscribe({
|
||||||
SubscriptionArn: this.subscriptionArn
|
SubscriptionArn: this.subscriptionArn
|
||||||
};
|
}).promise();
|
||||||
const response = await snsClient.send(new UnsubscribeCommand(params));
|
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) {
|
} catch (err) {
|
||||||
this.logger.error({err}, `Error unsubscribing to SNS topic arn ${AWS_SNS_TOPIC_ARM}`);
|
this.logger.error({err}, `Error unsubscribing to SNS topic arn ${process.env.AWS_SNS_TOPIC_ARM}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
completeScaleIn() {
|
completeScaleIn() {
|
||||||
assert(this.scaleInParams);
|
assert(this.scaleInParams);
|
||||||
autoScalingClient.send(new CompleteLifecycleActionCommand(this.scaleInParams))
|
autoscaling.completeLifecycleAction(this.scaleInParams, (err, response) => {
|
||||||
.then((data) => {
|
if (err) return this.logger.error({err}, 'Error completing scale-in');
|
||||||
return this.logger.info({data}, 'Successfully completed scale-in action');
|
this.logger.info({response}, 'Successfully completed scale-in action');
|
||||||
})
|
});
|
||||||
.catch((err) => {
|
|
||||||
this.logger.error({err}, 'Error completing scale-in');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describeInstance() {
|
describeInstance() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!this.instanceId) return reject('instance-id unknown');
|
if (!this.instanceId) return reject('instance-id unknown');
|
||||||
autoScalingClient.send(new DescribeAutoScalingGroupsCommand({
|
autoscaling.describeAutoScalingInstances({
|
||||||
InstanceIds: [this.instanceId]
|
InstanceIds: [this.instanceId]
|
||||||
}))
|
}, (err, data) => {
|
||||||
.then((data) => {
|
if (err) {
|
||||||
this.logger.info({data}, 'SnsNotifier: describeInstance');
|
|
||||||
return resolve(data);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
this.logger.error({err}, 'Error describing instances');
|
this.logger.error({err}, 'Error describing instances');
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
} else {
|
||||||
|
this.logger.info({data}, 'SnsNotifier: describeInstance');
|
||||||
|
resolve(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +166,7 @@ module.exports = async function(logger) {
|
|||||||
process.on('SIGHUP', async() => {
|
process.on('SIGHUP', async() => {
|
||||||
try {
|
try {
|
||||||
const data = await notifier.describeInstance();
|
const data = await notifier.describeInstance();
|
||||||
const state = data.AutoScalingGroups[0].Instances[0].LifecycleState;
|
const state = data.AutoScalingInstances[0].LifecycleState;
|
||||||
if (state !== notifier.lifecycleState) {
|
if (state !== notifier.lifecycleState) {
|
||||||
notifier.lifecycleState = state;
|
notifier.lifecycleState = state;
|
||||||
switch (state) {
|
switch (state) {
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
|
||||||
const makeTask = require('../tasks/make_task');
|
|
||||||
const { JAMBONZ_RECORD_WS_BASE_URL, JAMBONZ_RECORD_WS_USERNAME, JAMBONZ_RECORD_WS_PASSWORD } = require('../config');
|
|
||||||
const Emitter = require('events');
|
|
||||||
|
|
||||||
class BackgroundTaskManager extends Emitter {
|
|
||||||
constructor({cs, logger, rootSpan}) {
|
|
||||||
super();
|
|
||||||
this.tasks = new Map();
|
|
||||||
this.cs = cs;
|
|
||||||
this.logger = logger;
|
|
||||||
this.rootSpan = rootSpan;
|
|
||||||
}
|
|
||||||
|
|
||||||
isTaskRunning(type) {
|
|
||||||
return this.tasks.has(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
getTask(type) {
|
|
||||||
if (this.tasks.has(type)) {
|
|
||||||
return this.tasks.get(type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
count() {
|
|
||||||
return this.tasks.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
async newTask(type, taskOpts) {
|
|
||||||
this.logger.info({taskOpts}, `initiating Background task ${type}`);
|
|
||||||
if (this.tasks.has(type)) {
|
|
||||||
this.logger.info(`Background task ${type} is running, skiped`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let task;
|
|
||||||
switch (type) {
|
|
||||||
case 'listen':
|
|
||||||
task = await this._initListen(taskOpts);
|
|
||||||
break;
|
|
||||||
case 'bargeIn':
|
|
||||||
task = await this._initBargeIn(taskOpts);
|
|
||||||
break;
|
|
||||||
case 'record':
|
|
||||||
task = await this._initRecord();
|
|
||||||
break;
|
|
||||||
case 'transcribe':
|
|
||||||
task = await this._initTranscribe(taskOpts);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (task) {
|
|
||||||
this.tasks.set(type, task);
|
|
||||||
}
|
|
||||||
return task;
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(type) {
|
|
||||||
const task = this.getTask(type);
|
|
||||||
if (task) {
|
|
||||||
this.logger.info(`stopping background task: ${type}`);
|
|
||||||
task.removeAllListeners();
|
|
||||||
task.span.end();
|
|
||||||
task.kill();
|
|
||||||
// Remove task from managed List
|
|
||||||
this.tasks.delete(type);
|
|
||||||
} else {
|
|
||||||
this.logger.info(`stopping background task, ${type} is not running, skipped`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stopAll() {
|
|
||||||
this.logger.info('BackgroundTaskManager:stopAll');
|
|
||||||
for (const key of this.tasks.keys()) {
|
|
||||||
this.stop(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initiate Task
|
|
||||||
// Initiate Listen
|
|
||||||
async _initListen(opts, bugname = 'jambonz-background-listen', ignoreCustomerData = false, type = 'listen') {
|
|
||||||
let task;
|
|
||||||
try {
|
|
||||||
const t = normalizeJambones(this.logger, [opts]);
|
|
||||||
task = makeTask(this.logger, t[0]);
|
|
||||||
task.bugname = bugname;
|
|
||||||
task.ignoreCustomerData = ignoreCustomerData;
|
|
||||||
const resources = await this.cs._evaluatePreconditions(task);
|
|
||||||
const {span, ctx} = this.rootSpan.startChildSpan(`background-${type}:${task.summary}`);
|
|
||||||
task.span = span;
|
|
||||||
task.ctx = ctx;
|
|
||||||
task.exec(this.cs, resources)
|
|
||||||
.then(this._taskCompleted.bind(this, type, task))
|
|
||||||
.catch(this._taskError.bind(this, type, task));
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info({err, opts}, `BackgroundTaskManager:_initListen - Error creating ${bugname} task`);
|
|
||||||
}
|
|
||||||
return task;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initiate Gather
|
|
||||||
async _initBargeIn(opts) {
|
|
||||||
let task;
|
|
||||||
try {
|
|
||||||
const t = normalizeJambones(this.logger, [opts]);
|
|
||||||
task = makeTask(this.logger, t[0]);
|
|
||||||
task
|
|
||||||
.once('dtmf', this._bargeInTaskCompleted.bind(this))
|
|
||||||
.once('vad', this._bargeInTaskCompleted.bind(this))
|
|
||||||
.once('transcription', this._bargeInTaskCompleted.bind(this))
|
|
||||||
.once('timeout', this._bargeInTaskCompleted.bind(this));
|
|
||||||
const resources = await this.cs._evaluatePreconditions(task);
|
|
||||||
const {span, ctx} = this.rootSpan.startChildSpan(`background-bargeIn:${task.summary}`);
|
|
||||||
task.span = span;
|
|
||||||
task.ctx = ctx;
|
|
||||||
task.bugname_prefix = 'background_bargeIn_';
|
|
||||||
task.exec(this.cs, resources)
|
|
||||||
.then(() => {
|
|
||||||
this._taskCompleted('bargeIn', task);
|
|
||||||
if (task.sticky && !this.cs.callGone && !this.cs._stopping) {
|
|
||||||
this.logger.info('BackgroundTaskManager:_initBargeIn: restarting background bargeIn');
|
|
||||||
this.newTask('bargeIn', opts);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
})
|
|
||||||
.catch(this._taskError.bind(this, 'bargeIn', task));
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info(err, 'BackgroundTaskManager:_initGather - Error creating bargeIn task');
|
|
||||||
}
|
|
||||||
return task;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initiate Record
|
|
||||||
async _initRecord() {
|
|
||||||
if (this.cs.accountInfo.account.record_all_calls || this.cs.application.record_all_calls) {
|
|
||||||
if (!JAMBONZ_RECORD_WS_BASE_URL || !this.cs.accountInfo.account.bucket_credential) {
|
|
||||||
this.logger.error(`_initRecord: invalid configuration,
|
|
||||||
missing JAMBONZ_RECORD_WS_BASE_URL or bucket configuration`);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const listenOpts = {
|
|
||||||
url: `${JAMBONZ_RECORD_WS_BASE_URL}/record/${this.cs.accountInfo.account.bucket_credential.vendor}`,
|
|
||||||
disableBidirectionalAudio: true,
|
|
||||||
mixType : 'stereo',
|
|
||||||
passDtmf: true
|
|
||||||
};
|
|
||||||
if (JAMBONZ_RECORD_WS_USERNAME && JAMBONZ_RECORD_WS_PASSWORD) {
|
|
||||||
listenOpts.wsAuth = {
|
|
||||||
username: JAMBONZ_RECORD_WS_USERNAME,
|
|
||||||
password: JAMBONZ_RECORD_WS_PASSWORD
|
|
||||||
};
|
|
||||||
}
|
|
||||||
this.logger.debug({listenOpts}, '_initRecord: enabling listen');
|
|
||||||
return await this._initListen({verb: 'listen', ...listenOpts}, 'jambonz-session-record', true, 'record');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initiate Transcribe
|
|
||||||
async _initTranscribe(opts) {
|
|
||||||
let task;
|
|
||||||
try {
|
|
||||||
const t = normalizeJambones(this.logger, [opts]);
|
|
||||||
task = makeTask(this.logger, t[0]);
|
|
||||||
const resources = await this.cs._evaluatePreconditions(task);
|
|
||||||
const {span, ctx} = this.rootSpan.startChildSpan(`background-transcribe:${task.summary}`);
|
|
||||||
task.span = span;
|
|
||||||
task.ctx = ctx;
|
|
||||||
task.bugname_prefix = 'background_transcribe_';
|
|
||||||
task.exec(this.cs, resources)
|
|
||||||
.then(this._taskCompleted.bind(this, 'transcribe', task))
|
|
||||||
.catch(this._taskError.bind(this, 'transcribe', task));
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info(err, 'BackgroundTaskManager:_initTranscribe - Error creating transcribe task');
|
|
||||||
}
|
|
||||||
return task;
|
|
||||||
}
|
|
||||||
|
|
||||||
_taskCompleted(type, task) {
|
|
||||||
this.logger.info({type, task}, 'BackgroundTaskManager:_taskCompleted: task completed');
|
|
||||||
task.removeAllListeners();
|
|
||||||
task.span.end();
|
|
||||||
this.tasks.delete(type);
|
|
||||||
}
|
|
||||||
_taskError(type, task, error) {
|
|
||||||
this.logger.info({type, task, error}, 'BackgroundTaskManager:_taskError: task Error');
|
|
||||||
task.removeAllListeners();
|
|
||||||
task.span.end();
|
|
||||||
this.tasks.delete(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
_bargeInTaskCompleted(evt) {
|
|
||||||
this.logger.info({evt}, 'BackgroundTaskManager:_bargeInTaskCompleted on event from background bargeIn');
|
|
||||||
this.emit('bargeIn-done', evt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = BackgroundTaskManager;
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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 {
|
|
||||||
constructor(logger, account_sid, hook, secret) {
|
|
||||||
super();
|
|
||||||
assert(typeof hook === 'object');
|
|
||||||
|
|
||||||
this.logger = logger;
|
|
||||||
this.url = hook.url;
|
|
||||||
|
|
||||||
this.username = hook.username;
|
|
||||||
this.password = hook.password;
|
|
||||||
this.secret = secret;
|
|
||||||
this.account_sid = account_sid;
|
|
||||||
|
|
||||||
const {stats} = require('../../').srf.locals;
|
|
||||||
this.stats = stats;
|
|
||||||
|
|
||||||
if (!alerter) {
|
|
||||||
alerter = timeSeries(logger, {
|
|
||||||
host: JAMBONES_TIME_SERIES_HOST,
|
|
||||||
commitSize: 50,
|
|
||||||
commitInterval: 'test' === NODE_ENV ? 7 : 20
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get Alerter() {
|
|
||||||
return alerter;
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
/* subclass responsibility */
|
|
||||||
}
|
|
||||||
|
|
||||||
_computeSignature(payload, timestamp, secret) {
|
|
||||||
assert(secret);
|
|
||||||
const data = `${timestamp}.${JSON.stringify(payload)}`;
|
|
||||||
return crypto
|
|
||||||
.createHmac('sha256', secret)
|
|
||||||
.update(data, 'utf8')
|
|
||||||
.digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
_generateSigHeader(payload, secret) {
|
|
||||||
const timestamp = Math.floor(Date.now() / 1000);
|
|
||||||
const signature = this._computeSignature(payload, timestamp, secret);
|
|
||||||
const scheme = 'v1';
|
|
||||||
return {
|
|
||||||
'Jambonz-Signature': `t=${timestamp},${scheme}=${signature}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
_isAbsoluteUrl(u) {
|
|
||||||
return typeof u === 'string' &&
|
|
||||||
u.startsWith('https://') || u.startsWith('http://') ||
|
|
||||||
u.startsWith('ws://') || u.startsWith('wss://');
|
|
||||||
}
|
|
||||||
_isRelativeUrl(u) {
|
|
||||||
return typeof u === 'string' && u.startsWith('/');
|
|
||||||
}
|
|
||||||
_roundTrip(startAt) {
|
|
||||||
const diff = process.hrtime(startAt);
|
|
||||||
const time = diff[0] * 1e3 + diff[1] * 1e-6;
|
|
||||||
return time.toFixed(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = BaseRequestor;
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
const {context, trace} = require('@opentelemetry/api');
|
|
||||||
const {Dialog} = require('drachtio-srf');
|
|
||||||
class RootSpan {
|
|
||||||
constructor(callType, req) {
|
|
||||||
const {srf} = require('../../');
|
|
||||||
const tracer = srf.locals.otel.tracer;
|
|
||||||
let callSid, accountSid, applicationSid, linkedSpanId;
|
|
||||||
|
|
||||||
if (req instanceof Dialog) {
|
|
||||||
const dlg = req;
|
|
||||||
callSid = dlg.callSid;
|
|
||||||
linkedSpanId = dlg.linkedSpanId;
|
|
||||||
}
|
|
||||||
else if (req.srf) {
|
|
||||||
callSid = req.locals.callSid;
|
|
||||||
accountSid = req.get('X-Account-Sid'),
|
|
||||||
applicationSid = req.locals.application_sid;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
callSid = req.callSid;
|
|
||||||
accountSid = req.accountSid;
|
|
||||||
applicationSid = req.applicationSid;
|
|
||||||
}
|
|
||||||
this._span = tracer.startSpan(callType || 'incoming-call');
|
|
||||||
if (req instanceof Dialog) {
|
|
||||||
const dlg = req;
|
|
||||||
this._span.setAttributes({
|
|
||||||
linkedSpanId,
|
|
||||||
callId: dlg.sip.callId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else if (req.srf) {
|
|
||||||
this._span.setAttributes({
|
|
||||||
callSid,
|
|
||||||
accountSid,
|
|
||||||
applicationSid,
|
|
||||||
callId: req.get('Call-ID'),
|
|
||||||
externalCallId: req.get('X-CID')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this._span.setAttributes({
|
|
||||||
callSid,
|
|
||||||
accountSid,
|
|
||||||
applicationSid
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this._ctx = trace.setSpan(context.active(), this._span);
|
|
||||||
this.tracer = tracer;
|
|
||||||
}
|
|
||||||
|
|
||||||
get context() {
|
|
||||||
return this._ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
get traceId() {
|
|
||||||
return this._span.spanContext().traceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
get spanId() {
|
|
||||||
return this._span.spanContext().spanId;
|
|
||||||
}
|
|
||||||
|
|
||||||
get traceFlags() {
|
|
||||||
return this._span.spanContext().traceFlags;
|
|
||||||
}
|
|
||||||
|
|
||||||
getTracingPropagation(encoding) {
|
|
||||||
// TODO: support encodings beyond b3 https://github.com/openzipkin/b3-propagation
|
|
||||||
if (this._span && this.traceId !== '00000000000000000000000000000000') {
|
|
||||||
return `${this.traceId}-${this.spanId}-1`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setAttributes(attrs) {
|
|
||||||
this._span.setAttributes(attrs);
|
|
||||||
}
|
|
||||||
|
|
||||||
end() {
|
|
||||||
this._span.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
startChildSpan(name, attributes) {
|
|
||||||
const span = this.tracer.startSpan(name, attributes, this._ctx);
|
|
||||||
const ctx = trace.setSpan(context.active(), span);
|
|
||||||
return {span, ctx};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = RootSpan;
|
|
||||||
|
|
||||||
@@ -1,27 +1,19 @@
|
|||||||
{
|
{
|
||||||
"TaskName": {
|
"TaskName": {
|
||||||
"Cognigy": "cognigy",
|
|
||||||
"Conference": "conference",
|
"Conference": "conference",
|
||||||
"Config": "config",
|
|
||||||
"Dequeue": "dequeue",
|
"Dequeue": "dequeue",
|
||||||
"Dial": "dial",
|
"Dial": "dial",
|
||||||
"Dialogflow": "dialogflow",
|
"Dialogflow": "dialogflow",
|
||||||
"Dtmf": "dtmf",
|
|
||||||
"Enqueue": "enqueue",
|
"Enqueue": "enqueue",
|
||||||
"Gather": "gather",
|
"Gather": "gather",
|
||||||
"Hangup": "hangup",
|
"Hangup": "hangup",
|
||||||
"Leave": "leave",
|
"Leave": "leave",
|
||||||
"Lex": "lex",
|
|
||||||
"Listen": "listen",
|
"Listen": "listen",
|
||||||
"Message": "message",
|
|
||||||
"Pause": "pause",
|
"Pause": "pause",
|
||||||
"Play": "play",
|
"Play": "play",
|
||||||
"Rasa": "rasa",
|
|
||||||
"Redirect": "redirect",
|
"Redirect": "redirect",
|
||||||
"RestDial": "rest:dial",
|
"RestDial": "rest:dial",
|
||||||
"SipDecline": "sip:decline",
|
"SipDecline": "sip:decline",
|
||||||
"SipRequest": "sip:request",
|
|
||||||
"SipRefer": "sip:refer",
|
|
||||||
"SipNotify": "sip:notify",
|
"SipNotify": "sip:notify",
|
||||||
"SipRedirect": "sip:redirect",
|
"SipRedirect": "sip:redirect",
|
||||||
"Say": "say",
|
"Say": "say",
|
||||||
@@ -29,7 +21,6 @@
|
|||||||
"Tag": "tag",
|
"Tag": "tag",
|
||||||
"Transcribe": "transcribe"
|
"Transcribe": "transcribe"
|
||||||
},
|
},
|
||||||
"AllowedSipRecVerbs": ["config", "gather", "transcribe", "listen", "tag"],
|
|
||||||
"CallStatus": {
|
"CallStatus": {
|
||||||
"Trying": "trying",
|
"Trying": "trying",
|
||||||
"Ringing": "ringing",
|
"Ringing": "ringing",
|
||||||
@@ -43,94 +34,24 @@
|
|||||||
},
|
},
|
||||||
"CallDirection": {
|
"CallDirection": {
|
||||||
"Inbound": "inbound",
|
"Inbound": "inbound",
|
||||||
"Outbound": "outbound",
|
"Outbound": "outbound"
|
||||||
"None": "none"
|
|
||||||
},
|
},
|
||||||
"ListenStatus": {
|
"ListenStatus": {
|
||||||
"Pause": "pause",
|
"Pause": "pause",
|
||||||
"Silence": "silence",
|
"Silence": "silence",
|
||||||
"Resume": "resume"
|
"Resume": "resume"
|
||||||
},
|
},
|
||||||
"TranscribeStatus": {
|
|
||||||
"Pause": "pause",
|
|
||||||
"Silence": "silence",
|
|
||||||
"Resume": "resume"
|
|
||||||
},
|
|
||||||
"TaskPreconditions": {
|
"TaskPreconditions": {
|
||||||
"None": "none",
|
"None": "none",
|
||||||
"Endpoint": "endpoint",
|
"Endpoint": "endpoint",
|
||||||
"StableCall": "stable-call",
|
"StableCall": "stable-call",
|
||||||
"UnansweredCall": "unanswered-call"
|
"UnansweredCall": "unanswered-call"
|
||||||
},
|
},
|
||||||
"AvmdEvents": {
|
"TranscriptionEvents": {
|
||||||
"Beep": "avmd::beep"
|
|
||||||
},
|
|
||||||
"GoogleTranscriptionEvents": {
|
|
||||||
"Transcription": "google_transcribe::transcription",
|
"Transcription": "google_transcribe::transcription",
|
||||||
"EndOfUtterance": "google_transcribe::end_of_utterance",
|
"EndOfUtterance": "google_transcribe::end_of_utterance",
|
||||||
"NoAudioDetected": "google_transcribe::no_audio_detected",
|
"NoAudioDetected": "google_transcribe::no_audio_detected",
|
||||||
"MaxDurationExceeded": "google_transcribe::max_duration_exceeded",
|
"MaxDurationExceeded": "google_transcribe::max_duration_exceeded"
|
||||||
"VadDetected": "google_transcribe::vad_detected"
|
|
||||||
},
|
|
||||||
"NuanceTranscriptionEvents": {
|
|
||||||
"Transcription": "nuance_transcribe::transcription",
|
|
||||||
"StartOfSpeech": "nuance_transcribe::start_of_speech",
|
|
||||||
"TranscriptionComplete": "nuance_transcribe::end_of_transcription",
|
|
||||||
"Error": "nuance_transcribe::error",
|
|
||||||
"VadDetected": "nuance_transcribe::vad_detected"
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"CobaltTranscriptionEvents": {
|
|
||||||
"Transcription": "cobalt_speech::transcription",
|
|
||||||
"CompileContext": "cobalt_speech::compile_context_response",
|
|
||||||
"Error": "cobalt_speech::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",
|
|
||||||
"NoAudioDetected": "aws_transcribe::no_audio_detected",
|
|
||||||
"MaxDurationExceeded": "aws_transcribe::max_duration_exceeded",
|
|
||||||
"VadDetected": "aws_transcribe::vad_detected"
|
|
||||||
},
|
|
||||||
"AzureTranscriptionEvents": {
|
|
||||||
"Transcription": "azure_transcribe::transcription",
|
|
||||||
"StartOfUtterance": "azure_transcribe::start_of_utterance",
|
|
||||||
"EndOfUtterance": "azure_transcribe::end_of_utterance",
|
|
||||||
"NoSpeechDetected": "azure_transcribe::no_speech_detected",
|
|
||||||
"VadDetected": "azure_transcribe::vad_detected"
|
|
||||||
},
|
|
||||||
"JambonzTranscriptionEvents": {
|
|
||||||
"Transcription": "jambonz_transcribe::transcription",
|
|
||||||
"ConnectFailure": "jambonz_transcribe::connect_failed",
|
|
||||||
"Connect": "jambonz_transcribe::connect",
|
|
||||||
"Error": "jambonz_transcribe::error"
|
|
||||||
},
|
|
||||||
"AssemblyAiTranscriptionEvents": {
|
|
||||||
"Transcription": "assemblyai_transcribe::transcription",
|
|
||||||
"Error": "assemblyai_transcribe::error",
|
|
||||||
"ConnectFailure": "assemblyai_transcribe::connect_failed",
|
|
||||||
"Connect": "assemblyai_transcribe::connect"
|
|
||||||
},
|
},
|
||||||
"ListenEvents": {
|
"ListenEvents": {
|
||||||
"Connect": "mod_audio_fork::connect",
|
"Connect": "mod_audio_fork::connect",
|
||||||
@@ -161,38 +82,6 @@
|
|||||||
"Hangup": "hangup",
|
"Hangup": "hangup",
|
||||||
"Timeout": "timeout"
|
"Timeout": "timeout"
|
||||||
},
|
},
|
||||||
"KillReason": {
|
|
||||||
"Hangup": "hangup",
|
|
||||||
"Replaced": "replaced"
|
|
||||||
},
|
|
||||||
"HookMsgTypes": [
|
|
||||||
"session:new",
|
|
||||||
"session:reconnect",
|
|
||||||
"session:redirect",
|
|
||||||
"call:status",
|
|
||||||
"queue:status",
|
|
||||||
"dial:confirm",
|
|
||||||
"verb:hook",
|
|
||||||
"verb:status",
|
|
||||||
"jambonz:error"
|
|
||||||
],
|
|
||||||
"RecordState": {
|
|
||||||
"RecordingOn": "recording_on",
|
|
||||||
"RecordingOff": "recording_off",
|
|
||||||
"RecordingPaused": "recording_paused"
|
|
||||||
},
|
|
||||||
"AmdEvents": {
|
|
||||||
"NoSpeechDetected": "amd_no_speech_detected",
|
|
||||||
"HumanDetected": "amd_human_detected",
|
|
||||||
"MachineDetected": "amd_machine_detected",
|
|
||||||
"MachineStoppedSpeaking": "amd_machine_stopped_speaking",
|
|
||||||
"Error": "amd_error",
|
|
||||||
"DecisionTimeout": "amd_decision_timeout",
|
|
||||||
"ToneDetected": "amd_tone_detected",
|
|
||||||
"ToneTimeout": "amd_tone_timeout",
|
|
||||||
"Stopped": "amd_stopped"
|
|
||||||
},
|
|
||||||
"MAX_SIMRINGS": 10,
|
"MAX_SIMRINGS": 10,
|
||||||
"BONG_TONE": "tone_stream://v=-7;%(100,0,941.0,1477.0);v=-7;>=2;+=.1;%(1400,0,350,440)",
|
"BONG_TONE": "tone_stream://v=-7;%(100,0,941.0,1477.0);v=-7;>=2;+=.1;%(1400,0,350,440)"
|
||||||
"FS_UUID_SET_NAME": "fsUUIDs"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
const {execSync} = require('child_process');
|
|
||||||
const {
|
|
||||||
JAMBONES_FREESWITCH,
|
|
||||||
NODE_ENV,
|
|
||||||
JAMBONES_FREESWITCH_MAX_CALL_DURATION_MINS,
|
|
||||||
} = require('../config');
|
|
||||||
const now = Date.now();
|
|
||||||
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 (NODE_ENV === 'test') opts.listenAddress = '0.0.0.0';
|
|
||||||
return opts;
|
|
||||||
});
|
|
||||||
|
|
||||||
const clearChannels = () => {
|
|
||||||
const {logger} = require('../..');
|
|
||||||
const pwd = fsInventory[0].secret;
|
|
||||||
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')
|
|
||||||
.filter((line) => line.match(/^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{8}/))
|
|
||||||
.map((line) => {
|
|
||||||
const arr = line.split(',');
|
|
||||||
const dt = new Date(arr[2]);
|
|
||||||
const duration = (now - dt.getTime()) / 1000;
|
|
||||||
return {
|
|
||||||
uuid: arr[0],
|
|
||||||
time: arr[2],
|
|
||||||
duration
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((c) => c.duration > 60 * maxDurationMins);
|
|
||||||
|
|
||||||
if (calls.length > 0) {
|
|
||||||
logger.debug(`clearChannels: clearing ${calls.length} old calls longer than ${maxDurationMins} mins`);
|
|
||||||
for (const call of calls) {
|
|
||||||
const cmd = `/usr/local/freeswitch/bin/fs_cli -p ${pwd} -x "uuid_kill ${call.uuid}"`;
|
|
||||||
const out = execSync(cmd, {encoding: 'utf8'});
|
|
||||||
logger.debug({out}, 'clearChannels: command output');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return calls.length;
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearFiles = () => {
|
|
||||||
//const {logger} = require('../..');
|
|
||||||
/*const out = */ execSync('find /tmp -name "*.mp3" -mtime +2 -exec rm {} \\;');
|
|
||||||
//logger.debug({out}, 'clearFiles: command output');
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = {clearChannels, clearFiles};
|
|
||||||
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
const {decrypt} = require('./encrypt-decrypt');
|
|
||||||
|
|
||||||
const sqlAccountDetails = `SELECT *
|
|
||||||
FROM accounts account
|
|
||||||
WHERE account.account_sid = ?`;
|
|
||||||
const sqlSpeechCredentialsForAccount = `SELECT *
|
|
||||||
FROM speech_credentials
|
|
||||||
WHERE account_sid = ? OR (account_sid is NULL AND service_provider_sid =
|
|
||||||
(SELECT service_provider_sid from accounts where account_sid = ?))`;
|
|
||||||
const sqlQueryAccountCarrierByName = `SELECT voip_carrier_sid
|
|
||||||
FROM voip_carriers vc
|
|
||||||
WHERE vc.account_sid = ?
|
|
||||||
AND vc.name = ?`;
|
|
||||||
const sqlQuerySPCarrierByName = `SELECT voip_carrier_sid
|
|
||||||
FROM voip_carriers vc
|
|
||||||
WHERE vc.account_sid IS NULL
|
|
||||||
AND vc.service_provider_sid =
|
|
||||||
(SELECT service_provider_sid from accounts where account_sid = ?)
|
|
||||||
AND vc.name = ?`;
|
|
||||||
const 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 sqlQueryGoogleCustomVoices = `SELECT *
|
|
||||||
FROM google_custom_voices
|
|
||||||
WHERE google_custom_voice_sid = ?`;
|
|
||||||
|
|
||||||
const speechMapper = (cred) => {
|
|
||||||
const {credential, ...obj} = cred;
|
|
||||||
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.custom_stt_endpoint_url = o.custom_stt_endpoint_url;
|
|
||||||
obj.use_custom_tts = o.use_custom_tts;
|
|
||||||
obj.custom_tts_endpoint = o.custom_tts_endpoint;
|
|
||||||
obj.custom_tts_endpoint_url = o.custom_tts_endpoint_url;
|
|
||||||
}
|
|
||||||
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 ('cobalt' === obj.vendor) {
|
|
||||||
const o = JSON.parse(decrypt(credential));
|
|
||||||
obj.cobalt_server_uri = o.cobalt_server_uri;
|
|
||||||
} else if ('elevenlabs' === obj.vendor) {
|
|
||||||
const o = JSON.parse(decrypt(credential));
|
|
||||||
obj.api_key = o.api_key;
|
|
||||||
obj.model_id = o.model_id;
|
|
||||||
obj.options = o.options;
|
|
||||||
} else if ('assemblyai' === obj.vendor) {
|
|
||||||
const o = JSON.parse(decrypt(credential));
|
|
||||||
obj.api_key = o.api_key;
|
|
||||||
} else if ('whisper' === obj.vendor) {
|
|
||||||
const o = JSON.parse(decrypt(credential));
|
|
||||||
obj.api_key = o.api_key;
|
|
||||||
obj.model_id = o.model_id;
|
|
||||||
} 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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const bucketCredentialDecrypt = (account) => {
|
|
||||||
const { bucket_credential } = account.account;
|
|
||||||
if (!bucket_credential || bucket_credential.vendor) return;
|
|
||||||
account.account.bucket_credential = JSON.parse(decrypt(bucket_credential));
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = (logger, srf) => {
|
|
||||||
const {pool} = srf.locals.dbHelpers;
|
|
||||||
const pp = pool.promise();
|
|
||||||
|
|
||||||
const lookupAccountDetails = async(account_sid) => {
|
|
||||||
|
|
||||||
const [r] = await pp.query({sql: sqlAccountDetails, nestTables: true}, [account_sid]);
|
|
||||||
if (0 === r.length) throw new Error(`invalid accountSid: ${account_sid}`);
|
|
||||||
const [r2] = await pp.query(sqlSpeechCredentialsForAccount, [account_sid, account_sid]);
|
|
||||||
const speech = r2.map(speechMapper);
|
|
||||||
|
|
||||||
const account = r[0];
|
|
||||||
bucketCredentialDecrypt(account);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...account,
|
|
||||||
speech
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
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 {
|
|
||||||
await pp.execute(sql, [speech_credential_sid]);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({err}, `Error updating last_used for speech_credential_sid ${speech_credential_sid}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const lookupCarrier = async(account_sid, carrierName) => {
|
|
||||||
const pp = pool.promise();
|
|
||||||
try {
|
|
||||||
const [r] = await pp.query(sqlQueryAccountCarrierByName, [account_sid, carrierName]);
|
|
||||||
if (r.length) return r[0].voip_carrier_sid;
|
|
||||||
const [r2] = await pp.query(sqlQuerySPCarrierByName, [account_sid, carrierName]);
|
|
||||||
if (r2.length) return r2[0].voip_carrier_sid;
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({err}, `lookupCarrier: Error ${account_sid}:${carrierName}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const lookupGoogleCustomVoice = async(google_custom_voice_sid) => {
|
|
||||||
const pp = pool.promise();
|
|
||||||
try {
|
|
||||||
const [r] = await pp.query(sqlQueryGoogleCustomVoices, [google_custom_voice_sid]);
|
|
||||||
return r;
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({err}, `lookupGoogleCustomVoices: Error ${google_custom_voice_sid}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
lookupAccountDetails,
|
|
||||||
updateSpeechCredentialLastUsed,
|
|
||||||
lookupCarrier,
|
|
||||||
lookupCarrierByPhoneNumber,
|
|
||||||
lookupGoogleCustomVoice
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
const crypto = require('crypto');
|
|
||||||
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(ENCRYPTION_SECRET || JWT_SECRET)
|
|
||||||
.digest('base64')
|
|
||||||
.substring(0, 32);
|
|
||||||
|
|
||||||
const encrypt = (text) => {
|
|
||||||
const cipher = crypto.createCipheriv(algorithm, secretKey, iv);
|
|
||||||
const encrypted = Buffer.concat([cipher.update(text), cipher.final()]);
|
|
||||||
const data = {
|
|
||||||
iv: iv.toString('hex'),
|
|
||||||
content: encrypted.toString('hex')
|
|
||||||
};
|
|
||||||
return JSON.stringify(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
const decrypt = (data) => {
|
|
||||||
let hash;
|
|
||||||
try {
|
|
||||||
hash = JSON.parse(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.log(`failed to parse json string ${data}`);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
const decipher = crypto.createDecipheriv(algorithm, secretKey, Buffer.from(hash.iv, 'hex'));
|
|
||||||
const decrypted = Buffer.concat([decipher.update(Buffer.from(hash.content, 'hex')), decipher.final()]);
|
|
||||||
return decrypted.toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
encrypt,
|
|
||||||
decrypt
|
|
||||||
};
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
|
|
||||||
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;
|
|
||||||
srf.locals.serviceUrl = `http://${srf.locals.ipv4}:${port}`;
|
|
||||||
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,230 +0,0 @@
|
|||||||
const {request, getGlobalDispatcher, setGlobalDispatcher, Dispatcher, ProxyAgent, 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,
|
|
||||||
HTTP_PROXY_IP,
|
|
||||||
HTTP_PROXY_PORT,
|
|
||||||
HTTP_PROXY_PROTOCOL,
|
|
||||||
NODE_ENV,
|
|
||||||
} = require('../config');
|
|
||||||
|
|
||||||
const toBase64 = (str) => Buffer.from(str || '', 'utf8').toString('base64');
|
|
||||||
|
|
||||||
function basicAuth(username, password) {
|
|
||||||
if (!username || !password) return {};
|
|
||||||
const creds = `${username}:${password || ''}`;
|
|
||||||
const header = `Basic ${toBase64(creds)}`;
|
|
||||||
return {Authorization: header};
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultDispatcher = HTTP_PROXY_IP ?
|
|
||||||
new ProxyAgent(`${HTTP_PROXY_PROTOCOL}://${HTTP_PROXY_IP}${HTTP_PROXY_PORT ? `:${HTTP_PROXY_PORT}` : ''}`) :
|
|
||||||
getGlobalDispatcher();
|
|
||||||
|
|
||||||
setGlobalDispatcher(new class extends Dispatcher {
|
|
||||||
dispatch(options, handler) {
|
|
||||||
return defaultDispatcher.dispatch(options, handler);
|
|
||||||
}
|
|
||||||
}());
|
|
||||||
|
|
||||||
class HttpRequestor extends BaseRequestor {
|
|
||||||
constructor(logger, account_sid, hook, secret) {
|
|
||||||
super(logger, account_sid, hook, secret);
|
|
||||||
|
|
||||||
this.method = hook.method || 'POST';
|
|
||||||
this.authHeader = basicAuth(hook.username, hook.password);
|
|
||||||
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (NODE_ENV == 'test' && process.env.JAMBONES_HTTP_PROXY_IP) {
|
|
||||||
const defDispatcher =
|
|
||||||
new ProxyAgent(`${process.env.JAMBONES_HTTP_PROXY_PROTOCOL}://${process.env.JAMBONES_HTTP_PROXY_IP}${
|
|
||||||
process.env.JAMBONES_HTTP_PROXY_PORT ? `:${process.env.JAMBONES_HTTP_PROXY_PORT}` : ''}`);
|
|
||||||
|
|
||||||
setGlobalDispatcher(new class extends Dispatcher {
|
|
||||||
dispatch(options, handler) {
|
|
||||||
return defDispatcher.dispatch(options, handler);
|
|
||||||
}
|
|
||||||
}());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get baseUrl() {
|
|
||||||
return this._baseUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
if (!this._usePools && !this.client?.closed) this.client.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make an HTTP request.
|
|
||||||
* All requests use json bodies.
|
|
||||||
* All requests expect a 200 statusCode on success
|
|
||||||
* @param {object|string} hook - may be a absolute or relative url, or an object
|
|
||||||
* @param {string} [hook.url] - an absolute or relative url
|
|
||||||
* @param {string} [hook.method] - 'GET' or 'POST'
|
|
||||||
* @param {string} [hook.username] - if basic auth is protecting the endpoint
|
|
||||||
* @param {string} [hook.password] - if basic auth is protecting the endpoint
|
|
||||||
* @param {object} [params] - request parameters
|
|
||||||
*/
|
|
||||||
async request(type, hook, params, httpHeaders = {}) {
|
|
||||||
/* jambonz:error only sent over ws */
|
|
||||||
if (type === 'jambonz:error') return;
|
|
||||||
|
|
||||||
assert(HookMsgTypes.includes(type));
|
|
||||||
|
|
||||||
const payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null;
|
|
||||||
const url = hook.url || hook;
|
|
||||||
const method = hook.method || 'POST';
|
|
||||||
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 startAt = process.hrtime();
|
|
||||||
|
|
||||||
/* 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 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} = HTTP_PROXY_IP ? await request(
|
|
||||||
this.baseUrl,
|
|
||||||
{
|
|
||||||
path,
|
|
||||||
query,
|
|
||||||
method,
|
|
||||||
headers: hdrs,
|
|
||||||
...('POST' === method && {body: JSON.stringify(payload)}),
|
|
||||||
timeout: HTTP_TIMEOUT,
|
|
||||||
followRedirects: false
|
|
||||||
}
|
|
||||||
) : 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},
|
|
||||||
`web callback returned unexpected status code ${err.statusCode}`);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.logger.error({err, baseUrl: this.baseUrl, url},
|
|
||||||
'web callback returned unexpected error');
|
|
||||||
}
|
|
||||||
let opts = {account_sid: this.account_sid};
|
|
||||||
if (err.code === 'ECONNREFUSED') {
|
|
||||||
opts = {...opts, alert_type: this.Alerter.AlertType.WEBHOOK_CONNECTION_FAILURE, url};
|
|
||||||
}
|
|
||||||
else if (err.name === 'StatusError') {
|
|
||||||
opts = {...opts, alert_type: this.Alerter.AlertType.WEBHOOK_STATUS_FAILURE, url, status: err.statusCode};
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
opts = {...opts, alert_type: this.Alerter.AlertType.WEBHOOK_CONNECTION_FAILURE, url, detail: err.message};
|
|
||||||
}
|
|
||||||
this.Alerter.writeAlerts(opts).catch((err) => this.logger.info({err, opts}, 'Error writing alert'));
|
|
||||||
|
|
||||||
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 && Array.isArray(buf)) {
|
|
||||||
this.logger.info({response: buf}, `HttpRequestor:request ${method} ${url} succeeded in ${rtt}ms`);
|
|
||||||
return buf;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = HttpRequestor;
|
|
||||||
@@ -1,20 +1,7 @@
|
|||||||
const Mrf = require('drachtio-fsmrf');
|
const Mrf = require('drachtio-fsmrf');
|
||||||
const ip = require('ip');
|
const ip = require('ip');
|
||||||
const {
|
const localIp = ip.address();
|
||||||
JAMBONES_MYSQL_HOST,
|
const PORT = process.env.HTTP_PORT || 3000;
|
||||||
JAMBONES_MYSQL_USER,
|
|
||||||
JAMBONES_MYSQL_PASSWORD,
|
|
||||||
JAMBONES_MYSQL_DATABASE,
|
|
||||||
JAMBONES_MYSQL_CONNECTION_LIMIT,
|
|
||||||
JAMBONES_MYSQL_PORT,
|
|
||||||
JAMBONES_FREESWITCH,
|
|
||||||
SMPP_URL,
|
|
||||||
JAMBONES_TIME_SERIES_HOST,
|
|
||||||
JAMBONES_ESL_LISTEN_ADDRESS,
|
|
||||||
PORT,
|
|
||||||
NODE_ENV,
|
|
||||||
} = require('../config');
|
|
||||||
const Registrar = require('@jambonz/mw-registrar');
|
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
|
|
||||||
function initMS(logger, wrapper, ms) {
|
function initMS(logger, wrapper, ms) {
|
||||||
@@ -33,19 +20,11 @@ function initMS(logger, wrapper, ms) {
|
|||||||
wrapper.connects = 1;
|
wrapper.connects = 1;
|
||||||
wrapper.active = true;
|
wrapper.active = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
ms.on('channel::open', (evt) => {
|
|
||||||
logger.debug({evt}, `mediaserver ${ms.address} added endpoint`);
|
|
||||||
});
|
|
||||||
ms.on('channel::close', (evt) => {
|
|
||||||
logger.debug({evt}, `mediaserver ${ms.address} removed endpoint`);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function installSrfLocals(srf, logger) {
|
function installSrfLocals(srf, logger) {
|
||||||
logger.debug('installing srf locals');
|
logger.debug('installing srf locals');
|
||||||
assert(!srf.locals.dbHelpers);
|
assert(!srf.locals.dbHelpers);
|
||||||
const {tracer} = srf.locals.otel;
|
|
||||||
const {getSBC, lifecycleEmitter} = require('./sbc-pinger')(logger);
|
const {getSBC, lifecycleEmitter} = require('./sbc-pinger')(logger);
|
||||||
const StatsCollector = require('@jambonz/stats-collector');
|
const StatsCollector = require('@jambonz/stats-collector');
|
||||||
const stats = srf.locals.stats = new StatsCollector(logger);
|
const stats = srf.locals.stats = new StatsCollector(logger);
|
||||||
@@ -56,19 +35,12 @@ function installSrfLocals(srf, logger) {
|
|||||||
let idxStart = 0;
|
let idxStart = 0;
|
||||||
|
|
||||||
(async function() {
|
(async function() {
|
||||||
const fsInventory = JAMBONES_FREESWITCH
|
const fsInventory = process.env.JAMBONES_FREESWITCH
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((fs) => {
|
.map((fs) => {
|
||||||
const arr = /^([^:]*):([^:]*):([^:]*)(?::([^:]*))?/.exec(fs);
|
const arr = /^(.*):(.*):(.*)/.exec(fs);
|
||||||
assert.ok(arr, `Invalid syntax JAMBONES_FREESWITCH: ${JAMBONES_FREESWITCH}`);
|
assert.ok(arr, `Invalid syntax JAMBONES_FREESWITCH: ${process.env.JAMBONES_FREESWITCH}`);
|
||||||
const opts = {address: arr[1], port: arr[2], secret: arr[3]};
|
return {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 (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');
|
logger.info({fsInventory}, 'freeswitch inventory');
|
||||||
|
|
||||||
@@ -80,7 +52,7 @@ function installSrfLocals(srf, logger) {
|
|||||||
initMS(logger, val, ms);
|
initMS(logger, val, ms);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
logger.info({err}, `failed connecting to freeswitch at ${fs.address}, will retry shortly: ${err.message}`);
|
logger.info(`failed connecting to freeswitch at ${fs.address}, will retry shortly`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// retry to connect to any that were initially offline
|
// retry to connect to any that were initially offline
|
||||||
@@ -92,7 +64,7 @@ function installSrfLocals(srf, logger) {
|
|||||||
const ms = await mrf.connect(val.opts);
|
const ms = await mrf.connect(val.opts);
|
||||||
initMS(logger, val, ms);
|
initMS(logger, val, ms);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.info({err}, `failed connecting to freeswitch at ${val.opts.address}, will retry shortly`);
|
logger.info(`failed connecting to freeswitch at ${val.opts.address}, will retry shortly`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,7 +72,6 @@ function installSrfLocals(srf, logger) {
|
|||||||
|
|
||||||
// if we have a single freeswitch (as is typical) report stats periodically
|
// if we have a single freeswitch (as is typical) report stats periodically
|
||||||
if (mediaservers.length === 1) {
|
if (mediaservers.length === 1) {
|
||||||
srf.locals.mediaservers = [mediaservers[0].ms];
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
try {
|
try {
|
||||||
if (mediaservers[0].ms && mediaservers[0].active) {
|
if (mediaservers[0].ms && mediaservers[0].active) {
|
||||||
@@ -128,31 +99,24 @@ function installSrfLocals(srf, logger) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
pool,
|
|
||||||
lookupAppByPhoneNumber,
|
lookupAppByPhoneNumber,
|
||||||
lookupAppByRegex,
|
|
||||||
lookupAppBySid,
|
lookupAppBySid,
|
||||||
lookupAppByRealm,
|
lookupAppByRealm,
|
||||||
lookupAppByTeamsTenant,
|
lookupAppByTeamsTenant,
|
||||||
lookupTeamsByAccount,
|
lookupTeamsByAccount
|
||||||
lookupAccountBySid,
|
|
||||||
lookupAccountCapacitiesBySid,
|
|
||||||
lookupSmppGateways,
|
|
||||||
lookupClientByAccountAndUsername
|
|
||||||
} = require('@jambonz/db-helpers')({
|
} = require('@jambonz/db-helpers')({
|
||||||
host: JAMBONES_MYSQL_HOST,
|
host: process.env.JAMBONES_MYSQL_HOST,
|
||||||
user: JAMBONES_MYSQL_USER,
|
user: process.env.JAMBONES_MYSQL_USER,
|
||||||
port: JAMBONES_MYSQL_PORT || 3306,
|
password: process.env.JAMBONES_MYSQL_PASSWORD,
|
||||||
password: JAMBONES_MYSQL_PASSWORD,
|
database: process.env.JAMBONES_MYSQL_DATABASE,
|
||||||
database: JAMBONES_MYSQL_DATABASE,
|
connectionLimit: process.env.JAMBONES_MYSQL_CONNECTION_LIMIT || 10
|
||||||
connectionLimit: JAMBONES_MYSQL_CONNECTION_LIMIT || 10
|
|
||||||
}, logger);
|
}, logger);
|
||||||
const {
|
const {
|
||||||
client,
|
|
||||||
updateCallStatus,
|
updateCallStatus,
|
||||||
retrieveCall,
|
retrieveCall,
|
||||||
listCalls,
|
listCalls,
|
||||||
deleteCall,
|
deleteCall,
|
||||||
|
synthAudio,
|
||||||
createHash,
|
createHash,
|
||||||
retrieveHash,
|
retrieveHash,
|
||||||
deleteKey,
|
deleteKey,
|
||||||
@@ -161,55 +125,23 @@ function installSrfLocals(srf, logger) {
|
|||||||
retrieveSet,
|
retrieveSet,
|
||||||
addToSet,
|
addToSet,
|
||||||
removeFromSet,
|
removeFromSet,
|
||||||
monitorSet,
|
|
||||||
pushBack,
|
pushBack,
|
||||||
popFront,
|
popFront,
|
||||||
removeFromList,
|
removeFromList,
|
||||||
getListPosition,
|
|
||||||
lengthOfList,
|
lengthOfList,
|
||||||
addToSortedSet,
|
getListPosition
|
||||||
retrieveFromSortedSet,
|
} = require('@jambonz/realtimedb-helpers')({
|
||||||
retrieveByPatternSortedSet,
|
host: process.env.JAMBONES_REDIS_HOST,
|
||||||
sortedSetLength,
|
port: process.env.JAMBONES_REDIS_PORT || 6379
|
||||||
sortedSetPositionByPattern
|
}, logger);
|
||||||
} = require('@jambonz/realtimedb-helpers')({}, logger, tracer);
|
|
||||||
const registrar = new Registrar(logger, client);
|
|
||||||
const {
|
|
||||||
synthAudio,
|
|
||||||
getNuanceAccessToken,
|
|
||||||
getIbmAccessToken,
|
|
||||||
} = require('@jambonz/speech-utils')({}, logger);
|
|
||||||
const {
|
|
||||||
writeAlerts,
|
|
||||||
AlertType
|
|
||||||
} = require('@jambonz/time-series')(logger, {
|
|
||||||
host: JAMBONES_TIME_SERIES_HOST,
|
|
||||||
commitSize: 50,
|
|
||||||
commitInterval: 'test' === NODE_ENV ? 7 : 20
|
|
||||||
});
|
|
||||||
|
|
||||||
let localIp;
|
Object.assign(srf.locals, {
|
||||||
try {
|
|
||||||
localIp = ip.address();
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({err}, 'installSrfLocals - error detecting local ipv4 address');
|
|
||||||
}
|
|
||||||
|
|
||||||
srf.locals = {...srf.locals,
|
|
||||||
dbHelpers: {
|
dbHelpers: {
|
||||||
client,
|
|
||||||
registrar,
|
|
||||||
pool,
|
|
||||||
lookupAppByPhoneNumber,
|
lookupAppByPhoneNumber,
|
||||||
lookupAppByRegex,
|
|
||||||
lookupAppBySid,
|
lookupAppBySid,
|
||||||
lookupAppByRealm,
|
lookupAppByRealm,
|
||||||
lookupAppByTeamsTenant,
|
lookupAppByTeamsTenant,
|
||||||
lookupTeamsByAccount,
|
lookupTeamsByAccount,
|
||||||
lookupAccountBySid,
|
|
||||||
lookupAccountCapacitiesBySid,
|
|
||||||
lookupSmppGateways,
|
|
||||||
lookupClientByAccountAndUsername,
|
|
||||||
updateCallStatus,
|
updateCallStatus,
|
||||||
retrieveCall,
|
retrieveCall,
|
||||||
listCalls,
|
listCalls,
|
||||||
@@ -223,36 +155,20 @@ function installSrfLocals(srf, logger) {
|
|||||||
retrieveSet,
|
retrieveSet,
|
||||||
addToSet,
|
addToSet,
|
||||||
removeFromSet,
|
removeFromSet,
|
||||||
monitorSet,
|
|
||||||
pushBack,
|
pushBack,
|
||||||
popFront,
|
popFront,
|
||||||
removeFromList,
|
removeFromList,
|
||||||
lengthOfList,
|
lengthOfList,
|
||||||
getListPosition,
|
getListPosition
|
||||||
getNuanceAccessToken,
|
|
||||||
getIbmAccessToken,
|
|
||||||
addToSortedSet,
|
|
||||||
retrieveFromSortedSet,
|
|
||||||
retrieveByPatternSortedSet,
|
|
||||||
sortedSetLength,
|
|
||||||
sortedSetPositionByPattern
|
|
||||||
},
|
},
|
||||||
parentLogger: logger,
|
parentLogger: logger,
|
||||||
|
ipv4: localIp,
|
||||||
|
serviceUrl: `http://${localIp}:${PORT}`,
|
||||||
getSBC,
|
getSBC,
|
||||||
getSmpp: () => {
|
|
||||||
return SMPP_URL;
|
|
||||||
},
|
|
||||||
lifecycleEmitter,
|
lifecycleEmitter,
|
||||||
getFreeswitch,
|
getFreeswitch,
|
||||||
stats: stats,
|
stats: stats
|
||||||
writeAlerts,
|
});
|
||||||
AlertType
|
|
||||||
};
|
|
||||||
|
|
||||||
if (localIp) {
|
|
||||||
srf.locals.ipv4 = localIp;
|
|
||||||
srf.locals.serviceUrl = `http://${localIp}:${PORT}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = installSrfLocals;
|
module.exports = installSrfLocals;
|
||||||
|
|||||||
31
lib/utils/normalize-jambones.js
Normal file
31
lib/utils/normalize-jambones.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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,43 +4,35 @@ const SipError = require('drachtio-srf').SipError;
|
|||||||
const {TaskPreconditions, CallDirection} = require('../utils/constants');
|
const {TaskPreconditions, CallDirection} = require('../utils/constants');
|
||||||
const CallInfo = require('../session/call-info');
|
const CallInfo = require('../session/call-info');
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
|
||||||
const makeTask = require('../tasks/make_task');
|
|
||||||
const ConfirmCallSession = require('../session/confirm-call-session');
|
const ConfirmCallSession = require('../session/confirm-call-session');
|
||||||
const AdultingCallSession = require('../session/adulting-call-session');
|
const selectSbc = require('./select-sbc');
|
||||||
|
const Registrar = require('jambonz-mw-registrar');
|
||||||
|
const registrar = new Registrar({
|
||||||
|
host: process.env.JAMBONES_REDIS_HOST,
|
||||||
|
port: process.env.JAMBONES_REDIS_PORT || 6379
|
||||||
|
});
|
||||||
const deepcopy = require('deepcopy');
|
const deepcopy = require('deepcopy');
|
||||||
const moment = require('moment');
|
const moment = require('moment');
|
||||||
const stripCodecs = require('./strip-ancillary-codecs');
|
const uuidv4 = require('uuid/v4');
|
||||||
const RootSpan = require('./call-tracer');
|
|
||||||
const uuidv4 = require('uuid-random');
|
|
||||||
const HttpRequestor = require('./http-requestor');
|
|
||||||
const WsRequestor = require('./ws-requestor');
|
|
||||||
const {makeOpusFirst} = require('./sdp-utils');
|
|
||||||
|
|
||||||
class SingleDialer extends Emitter {
|
class SingleDialer extends Emitter {
|
||||||
constructor({logger, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan, dialTask}) {
|
constructor({logger, sbcAddress, target, opts, application, callInfo}) {
|
||||||
super();
|
super();
|
||||||
assert(target.type);
|
assert(target.type);
|
||||||
|
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.target = target;
|
this.target = target;
|
||||||
this.from = target.from || {};
|
|
||||||
this.sbcAddress = sbcAddress;
|
this.sbcAddress = sbcAddress;
|
||||||
this.opts = opts;
|
this.opts = opts;
|
||||||
this.application = application;
|
this.application = application;
|
||||||
this.confirmHook = target.confirmHook;
|
this.confirmHook = target.confirmHook;
|
||||||
this.rootSpan = rootSpan;
|
|
||||||
this.startSpan = startSpan;
|
|
||||||
|
|
||||||
this.bindings = logger.bindings();
|
this.bindings = logger.bindings();
|
||||||
|
|
||||||
this.parentCallInfo = callInfo;
|
this.parentCallInfo = callInfo;
|
||||||
this.accountInfo = accountInfo;
|
|
||||||
|
|
||||||
this.callGone = false;
|
this.callGone = false;
|
||||||
|
|
||||||
this.callSid = uuidv4();
|
this.callSid = uuidv4();
|
||||||
this.dialTask = dialTask;
|
|
||||||
|
|
||||||
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
|
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
|
||||||
}
|
}
|
||||||
@@ -49,10 +41,6 @@ class SingleDialer extends Emitter {
|
|||||||
return this.callInfo.callStatus;
|
return this.callInfo.callStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
get applicationSid() {
|
|
||||||
return this.application?.application_sid || this.callInfo?.applicationSid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* can be used for all http requests within this session
|
* can be used for all http requests within this session
|
||||||
*/
|
*/
|
||||||
@@ -71,25 +59,7 @@ class SingleDialer extends Emitter {
|
|||||||
|
|
||||||
async exec(srf, ms, opts) {
|
async exec(srf, ms, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
opts.headers = opts.headers || {};
|
let uri, to;
|
||||||
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,
|
|
||||||
...(this.applicationSid && {'X-Application-Sid': this.applicationSid}),
|
|
||||||
...(this.target.proxy && {'X-SIP-Proxy': this.target.proxy})
|
|
||||||
};
|
|
||||||
if (srf.locals.fsUUID) {
|
|
||||||
opts.headers = {
|
|
||||||
...opts.headers,
|
|
||||||
'X-Jambonz-FS-UUID': srf.locals.fsUUID,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
this.ms = ms;
|
|
||||||
let uri, to, inviteSpan;
|
|
||||||
try {
|
try {
|
||||||
switch (this.target.type) {
|
switch (this.target.type) {
|
||||||
case 'phone':
|
case 'phone':
|
||||||
@@ -99,22 +69,28 @@ class SingleDialer extends Emitter {
|
|||||||
to = this.target.number;
|
to = this.target.number;
|
||||||
if ('teams' === this.target.type) {
|
if ('teams' === this.target.type) {
|
||||||
assert(this.target.teamsInfo);
|
assert(this.target.teamsInfo);
|
||||||
opts.headers = {...opts.headers,
|
opts.headers = opts.headers || {};
|
||||||
|
Object.assign(opts.headers, {
|
||||||
'X-MS-Teams-FQDN': this.target.teamsInfo.ms_teams_fqdn,
|
'X-MS-Teams-FQDN': this.target.teamsInfo.ms_teams_fqdn,
|
||||||
'X-MS-Teams-Tenant-FQDN': this.target.teamsInfo.tenant_fqdn
|
'X-MS-Teams-Tenant-FQDN': this.target.teamsInfo.tenant_fqdn
|
||||||
};
|
});
|
||||||
if (this.target.vmail === true) uri = `${uri};opaque=app:voicemail`;
|
if (this.target.vmail === true) uri = `${uri};opaque=app:voicemail`;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'user':
|
case 'user':
|
||||||
assert(this.target.name);
|
assert(this.target.name);
|
||||||
|
const aor = this.target.name;
|
||||||
uri = `sip:${this.target.name}`;
|
uri = `sip:${this.target.name}`;
|
||||||
to = this.target.name;
|
to = this.target.name;
|
||||||
|
|
||||||
if (this.target.overrideTo) {
|
// need to send to the SBC registered on
|
||||||
Object.assign(opts.headers, {
|
const reg = await registrar.query(aor);
|
||||||
'X-Override-To': this.target.overrideTo
|
if (reg) {
|
||||||
});
|
const sbc = selectSbc(reg.sbcAddress);
|
||||||
|
if (sbc) {
|
||||||
|
this.logger.debug(`SingleDialer:exec retrieved registration details for ${aor}, using sbc at ${sbc}`);
|
||||||
|
this.sbcAddress = sbc;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'sip':
|
case 'sip':
|
||||||
@@ -132,17 +108,6 @@ class SingleDialer extends Emitter {
|
|||||||
|
|
||||||
this.ep = await ms.createEndpoint();
|
this.ep = await ms.createEndpoint();
|
||||||
this.logger.debug(`SingleDialer:exec - created endpoint ${this.ep.uuid}`);
|
this.logger.debug(`SingleDialer:exec - created endpoint ${this.ep.uuid}`);
|
||||||
|
|
||||||
/**
|
|
||||||
* were we killed whilst we were off getting an endpoint ?
|
|
||||||
* https://github.com/jambonz/jambonz-feature-server/issues/30
|
|
||||||
*/
|
|
||||||
if (this.killed) {
|
|
||||||
this.logger.info('SingleDialer:exec got quick CANCEL from caller, abort outdial');
|
|
||||||
this.ep.destroy()
|
|
||||||
.catch((err) => this.logger.error({err}, 'Error destroying endpoint'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let lastSdp;
|
let lastSdp;
|
||||||
const connectStream = async(remoteSdp) => {
|
const connectStream = async(remoteSdp) => {
|
||||||
if (remoteSdp === lastSdp) return;
|
if (remoteSdp === lastSdp) return;
|
||||||
@@ -152,42 +117,28 @@ class SingleDialer extends Emitter {
|
|||||||
|
|
||||||
Object.assign(opts, {
|
Object.assign(opts, {
|
||||||
proxy: `sip:${this.sbcAddress}`,
|
proxy: `sip:${this.sbcAddress}`,
|
||||||
localSdp: opts.opusFirst ? makeOpusFirst(this.ep.local.sdp) : this.ep.local.sdp
|
localSdp: this.ep.local.sdp
|
||||||
});
|
});
|
||||||
if (this.target.auth) opts.auth = this.target.auth;
|
if (this.target.auth) opts.auth = this.target.auth;
|
||||||
inviteSpan = this.startSpan('invite', {
|
this.dlg = await srf.createUAC(uri, opts, {
|
||||||
'invite.uri': uri,
|
|
||||||
'invite.dest_type': this.target.type
|
|
||||||
});
|
|
||||||
|
|
||||||
this.dlg = await srf.createUAC(uri, {...opts, followRedirects: true, keepUriOnRedirect: true}, {
|
|
||||||
cbRequest: (err, req) => {
|
cbRequest: (err, req) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
this.logger.error(err, 'SingleDialer:exec Error creating call');
|
this.logger.error(err, 'SingleDialer:exec Error creating call');
|
||||||
this.emit('callCreateFail', err);
|
this.emit('callCreateFail', err);
|
||||||
inviteSpan.setAttributes({
|
|
||||||
'invite.status_code': 500,
|
|
||||||
'invite.err': err.message
|
|
||||||
});
|
|
||||||
inviteSpan.end();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
inviteSpan.setAttributes({'invite.call_id': req.get('Call-ID')});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* INVITE has been sent out
|
* INVITE has been sent out
|
||||||
* (a) create a CallInfo for this call
|
* (a) create a CallInfo for this call
|
||||||
* (a) create a logger for this call
|
* (a) create a logger for this call
|
||||||
*/
|
*/
|
||||||
req.srf = srf;
|
|
||||||
this.req = req;
|
|
||||||
this.callInfo = new CallInfo({
|
this.callInfo = new CallInfo({
|
||||||
direction: CallDirection.Outbound,
|
direction: CallDirection.Outbound,
|
||||||
parentCallInfo: this.parentCallInfo,
|
parentCallInfo: this.parentCallInfo,
|
||||||
req,
|
req,
|
||||||
to,
|
to,
|
||||||
callSid: this.callSid,
|
callSid: this.callSid
|
||||||
traceId: this.rootSpan.traceId
|
|
||||||
});
|
});
|
||||||
this.logger = srf.locals.parentLogger.child({
|
this.logger = srf.locals.parentLogger.child({
|
||||||
callSid: this.callSid,
|
callSid: this.callSid,
|
||||||
@@ -195,14 +146,10 @@ class SingleDialer extends Emitter {
|
|||||||
callId: this.callInfo.callId
|
callId: this.callInfo.callId
|
||||||
});
|
});
|
||||||
this.inviteInProgress = req;
|
this.inviteInProgress = req;
|
||||||
this.emit('callStatusChange', {
|
this.emit('callStatusChange', {callStatus: CallStatus.Trying, sipStatus: 100});
|
||||||
callStatus: CallStatus.Trying,
|
|
||||||
sipStatus: 100,
|
|
||||||
sipReason: 'Trying'
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
cbProvisional: (prov) => {
|
cbProvisional: (prov) => {
|
||||||
const status = {sipStatus: prov.status, sipReason: prov.reason};
|
const status = {sipStatus: prov.status};
|
||||||
if ([180, 183].includes(prov.status) && prov.body) {
|
if ([180, 183].includes(prov.status) && prov.body) {
|
||||||
if (status.callStatus !== CallStatus.EarlyMedia) {
|
if (status.callStatus !== CallStatus.EarlyMedia) {
|
||||||
status.callStatus = CallStatus.EarlyMedia;
|
status.callStatus = CallStatus.EarlyMedia;
|
||||||
@@ -214,88 +161,44 @@ class SingleDialer extends Emitter {
|
|||||||
this.emit('callStatusChange', status);
|
this.emit('callStatusChange', status);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await connectStream(this.dlg.remote.sdp);
|
|
||||||
this.dlg.callSid = this.callSid;
|
|
||||||
this.inviteInProgress = null;
|
this.inviteInProgress = null;
|
||||||
this.emit('callStatusChange', {
|
this.dlg.callSid = this.callSid;
|
||||||
sipStatus: 200,
|
await connectStream(this.dlg.remote.sdp);
|
||||||
sipReason: 'OK',
|
this.emit('callStatusChange', {sipStatus: 200, callStatus: CallStatus.InProgress});
|
||||||
callStatus: CallStatus.InProgress
|
|
||||||
});
|
|
||||||
this.logger.debug(`SingleDialer:exec call connected: ${this.callSid}`);
|
this.logger.debug(`SingleDialer:exec call connected: ${this.callSid}`);
|
||||||
const connectTime = this.dlg.connectTime = moment();
|
const connectTime = this.dlg.connectTime = moment();
|
||||||
inviteSpan.setAttributes({'invite.status_code': 200});
|
|
||||||
inviteSpan.end();
|
|
||||||
|
|
||||||
|
|
||||||
/* race condition: we were killed just as call was answered */
|
|
||||||
if (this.killed) {
|
|
||||||
this.logger.info(`SingleDialer:exec race condition - we were killed as call connected: ${this.callSid}`);
|
|
||||||
const duration = moment().diff(connectTime, 'seconds');
|
|
||||||
this.emit('callStatusChange', {
|
|
||||||
callStatus: CallStatus.Completed,
|
|
||||||
sipStatus: 487,
|
|
||||||
sipReason: 'Request Terminated',
|
|
||||||
duration
|
|
||||||
});
|
|
||||||
if (this.ep) this.ep.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dlg
|
this.dlg
|
||||||
.on('destroy', () => {
|
.on('destroy', () => {
|
||||||
const duration = moment().diff(connectTime, 'seconds');
|
const duration = moment().diff(connectTime, 'seconds');
|
||||||
this.logger.debug('SingleDialer:exec called party hung up');
|
this.logger.debug('SingleDialer:exec called party hung up');
|
||||||
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||||
this.ep && this.ep.destroy();
|
this.ep.destroy();
|
||||||
})
|
})
|
||||||
.on('refresh', () => this.logger.info('SingleDialer:exec - dialog refreshed by uas'))
|
.on('refresh', () => this.logger.info('SingleDialer:exec - dialog refreshed by uas'))
|
||||||
.on('modify', async(req, res) => {
|
.on('modify', async(req, res) => {
|
||||||
try {
|
try {
|
||||||
if (this.ep) {
|
const newSdp = await this.ep.modify(req.body);
|
||||||
if (this.dialTask && this.dialTask.isOnHold) {
|
res.send(200, {body: newSdp});
|
||||||
this.logger.info('dial is onhold, emit event');
|
this.logger.info({offer: req.body, answer: newSdp}, 'SingleDialer:exec: handling reINVITE');
|
||||||
this.emit('reinvite', req, res);
|
|
||||||
} else {
|
|
||||||
const newSdp = await this.ep.modify(req.body);
|
|
||||||
res.send(200, {body: newSdp});
|
|
||||||
this.logger.info({offer: req.body, answer: newSdp}, 'SingleDialer:exec: handling reINVITE');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.logger.info('SingleDialer:exec: handling reINVITE with released media, emit event');
|
|
||||||
this.emit('reinvite', req, res);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(err, 'Error handling reinvite');
|
this.logger.error(err, 'Error handling reinvite');
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.on('refer', (req, res) => {
|
|
||||||
this.emit('refer', this.callInfo, req, res);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.confirmHook) this._executeApp(this.confirmHook);
|
if (this.confirmHook) this._executeApp(this.confirmHook);
|
||||||
else this.emit('accept');
|
else this.emit('accept');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.inviteInProgress = null;
|
|
||||||
const status = {callStatus: CallStatus.Failed};
|
const status = {callStatus: CallStatus.Failed};
|
||||||
if (err instanceof SipError) {
|
if (err instanceof SipError) {
|
||||||
status.sipStatus = err.status;
|
status.sipStatus = err.status;
|
||||||
status.sipReason = err.reason;
|
|
||||||
if (err.status === 487) status.callStatus = CallStatus.NoAnswer;
|
if (err.status === 487) status.callStatus = CallStatus.NoAnswer;
|
||||||
else if ([486, 600].includes(err.status)) status.callStatus = CallStatus.Busy;
|
else if ([486, 600].includes(err.status)) status.callStatus = CallStatus.Busy;
|
||||||
this.logger.info(`SingleDialer:exec outdial failure ${err.status}`);
|
this.logger.info(`SingleDialer:exec outdial failure ${err.status}`);
|
||||||
inviteSpan.setAttributes({'invite.status_code': err.status});
|
|
||||||
inviteSpan.end();
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
this.logger.error(err, 'SingleDialer:exec');
|
this.logger.error(err, 'SingleDialer:exec');
|
||||||
status.sipStatus = 500;
|
status.sipStatus = 500;
|
||||||
inviteSpan.setAttributes({
|
|
||||||
'invite.status_code': 500,
|
|
||||||
'invite.err': err.message
|
|
||||||
});
|
|
||||||
inviteSpan.end();
|
|
||||||
}
|
}
|
||||||
this.emit('callStatusChange', status);
|
this.emit('callStatusChange', status);
|
||||||
if (this.ep) this.ep.destroy();
|
if (this.ep) this.ep.destroy();
|
||||||
@@ -306,7 +209,6 @@ class SingleDialer extends Emitter {
|
|||||||
* kill the call in progress or the stable dialog, whichever we have
|
* kill the call in progress or the stable dialog, whichever we have
|
||||||
*/
|
*/
|
||||||
async kill() {
|
async kill() {
|
||||||
this.killed = true;
|
|
||||||
if (this.inviteInProgress) await this.inviteInProgress.cancel();
|
if (this.inviteInProgress) await this.inviteInProgress.cancel();
|
||||||
else if (this.dlg && this.dlg.connected) {
|
else if (this.dlg && this.dlg.connected) {
|
||||||
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
||||||
@@ -330,16 +232,11 @@ class SingleDialer extends Emitter {
|
|||||||
async _executeApp(confirmHook) {
|
async _executeApp(confirmHook) {
|
||||||
try {
|
try {
|
||||||
// retrieve set of tasks
|
// retrieve set of tasks
|
||||||
const json = await this.requestor.request('dial:confirm', confirmHook, this.callInfo.toJSON());
|
const tasks = await this.requestor.request(confirmHook, this.callInfo);
|
||||||
if (!json || (Array.isArray(json) && json.length === 0)) {
|
|
||||||
this.logger.info('SingleDialer:_executeApp: no tasks returned from confirm hook');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
|
||||||
// verify it contains only allowed verbs
|
// verify it contains only allowed verbs
|
||||||
const allowedTasks = tasks.filter((task) => {
|
const allowedTasks = tasks.filter((task) => {
|
||||||
return [
|
return [
|
||||||
TaskPreconditions.None,
|
|
||||||
TaskPreconditions.StableCall,
|
TaskPreconditions.StableCall,
|
||||||
TaskPreconditions.Endpoint
|
TaskPreconditions.Endpoint
|
||||||
].includes(task.preconditions);
|
].includes(task.preconditions);
|
||||||
@@ -356,9 +253,7 @@ class SingleDialer extends Emitter {
|
|||||||
dlg: this.dlg,
|
dlg: this.dlg,
|
||||||
ep: this.ep,
|
ep: this.ep,
|
||||||
callInfo: this.callInfo,
|
callInfo: this.callInfo,
|
||||||
accountInfo: this.accountInfo,
|
tasks
|
||||||
tasks,
|
|
||||||
rootSpan: this.rootSpan
|
|
||||||
});
|
});
|
||||||
await cs.exec();
|
await cs.exec();
|
||||||
|
|
||||||
@@ -371,88 +266,16 @@ class SingleDialer extends Emitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async doAdulting({logger, tasks, application}) {
|
_notifyCallStatusChange({callStatus, sipStatus, duration}) {
|
||||||
this.adulting = true;
|
|
||||||
this.emit('adulting');
|
|
||||||
if (this.ep) {
|
|
||||||
await this.ep.unbridge()
|
|
||||||
.catch((err) => this.logger.info({err}, 'SingleDialer:doAdulting - failed to unbridge ep'));
|
|
||||||
this.ep.play('silence_stream://1000');
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
await this.reAnchorMedia();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dlg.callSid = this.callSid;
|
|
||||||
this.dlg.linkedSpanId = this.rootSpan.traceId;
|
|
||||||
const rootSpan = new RootSpan('outbound-call', this.dlg);
|
|
||||||
const newLogger = logger.child({traceId: rootSpan.traceId});
|
|
||||||
//clone application from parent call with new requestor
|
|
||||||
//parrent application will be closed in case the parent hangup
|
|
||||||
const app = {...application};
|
|
||||||
if ('WS' === app.call_hook?.method ||
|
|
||||||
app.call_hook?.url.startsWith('ws://') || app.call_hook?.url.startsWith('wss://')) {
|
|
||||||
const requestor = new WsRequestor(logger, this.accountInfo.account.account_sid,
|
|
||||||
app.call_hook, this.accountInfo.account.webhook_secret);
|
|
||||||
app.requestor = requestor;
|
|
||||||
app.notifier = requestor;
|
|
||||||
app.call_hook.method = 'WS';
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
app.requestor = new HttpRequestor(logger, this.accountInfo.account.account_sid,
|
|
||||||
app.call_hook, this.accountInfo.account.webhook_secret);
|
|
||||||
if (app.call_status_hook) app.notifier = new HttpRequestor(logger,
|
|
||||||
this.accountInfo.account.account_sid, app.call_status_hook,
|
|
||||||
this.accountInfo.account.webhook_secret);
|
|
||||||
else app.notifier = {request: () => {}, close: () => {}};
|
|
||||||
}
|
|
||||||
const cs = new AdultingCallSession({
|
|
||||||
logger: newLogger,
|
|
||||||
singleDialer: this,
|
|
||||||
application: app,
|
|
||||||
callInfo: this.callInfo,
|
|
||||||
accountInfo: this.accountInfo,
|
|
||||||
tasks,
|
|
||||||
rootSpan
|
|
||||||
});
|
|
||||||
cs.req = this.req;
|
|
||||||
cs.exec().catch((err) => newLogger.error({err}, 'doAdulting: error executing session'));
|
|
||||||
return cs;
|
|
||||||
}
|
|
||||||
|
|
||||||
async releaseMediaToSBC(remoteSdp, localSdp) {
|
|
||||||
assert(this.dlg && this.dlg.connected && this.ep && typeof remoteSdp === 'string');
|
|
||||||
const sdp = stripCodecs(this.logger, remoteSdp, localSdp) || remoteSdp;
|
|
||||||
await this.dlg.modify(sdp, {
|
|
||||||
headers: {
|
|
||||||
'X-Reason': 'release-media'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.ep.destroy()
|
|
||||||
.then(() => this.ep = null)
|
|
||||||
.catch((err) => this.logger.error({err}, 'SingleDialer:releaseMediaToSBC: Error destroying endpoint'));
|
|
||||||
}
|
|
||||||
|
|
||||||
async reAnchorMedia() {
|
|
||||||
assert(this.dlg && this.dlg.connected && !this.ep);
|
|
||||||
this.ep = await this.ms.createEndpoint({remoteSdp: this.dlg.remote.sdp});
|
|
||||||
await this.dlg.modify(this.ep.local.sdp, {
|
|
||||||
headers: {
|
|
||||||
'X-Reason': 'anchor-media'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_notifyCallStatusChange({callStatus, sipStatus, sipReason, duration}) {
|
|
||||||
assert((typeof duration === 'number' && callStatus === CallStatus.Completed) ||
|
assert((typeof duration === 'number' && callStatus === CallStatus.Completed) ||
|
||||||
(!duration && callStatus !== CallStatus.Completed),
|
(!duration && callStatus !== CallStatus.Completed),
|
||||||
'duration MUST be supplied when call completed AND ONLY when call completed');
|
'duration MUST be supplied when call completed AND ONLY when call completed');
|
||||||
|
|
||||||
if (this.callInfo) {
|
if (this.callInfo) {
|
||||||
this.callInfo.updateCallStatus(callStatus, sipStatus, sipReason);
|
this.callInfo.updateCallStatus(callStatus, sipStatus);
|
||||||
if (typeof duration === 'number') this.callInfo.duration = duration;
|
if (typeof duration === 'number') this.callInfo.duration = duration;
|
||||||
try {
|
try {
|
||||||
this.notifier.request('call:status', this.application.call_status_hook, this.callInfo.toJSON());
|
this.requestor.request(this.application.call_status_hook, this.callInfo.toJSON());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.info(err, `SingleDialer:_notifyCallStatusChange error sending ${callStatus} ${sipStatus}`);
|
this.logger.info(err, `SingleDialer:_notifyCallStatusChange error sending ${callStatus} ${sipStatus}`);
|
||||||
}
|
}
|
||||||
@@ -465,13 +288,9 @@ class SingleDialer extends Emitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function placeOutdial({
|
function placeOutdial({logger, srf, ms, sbcAddress, target, opts, application, callInfo}) {
|
||||||
logger, srf, ms, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan, dialTask
|
|
||||||
}) {
|
|
||||||
const myOpts = deepcopy(opts);
|
const myOpts = deepcopy(opts);
|
||||||
const sd = new SingleDialer({
|
const sd = new SingleDialer({logger, sbcAddress, target, myOpts, application, callInfo});
|
||||||
logger, sbcAddress, target, myOpts, application, callInfo, accountInfo, rootSpan, startSpan, dialTask
|
|
||||||
});
|
|
||||||
sd.exec(srf, ms, myOpts);
|
sd.exec(srf, ms, myOpts);
|
||||||
return sd;
|
return sd;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
|
const bent = require('bent');
|
||||||
|
const parseUrl = require('parse-url');
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
const timeSeries = require('@jambonz/time-series');
|
|
||||||
const {
|
const toBase64 = (str) => Buffer.from(str || '', 'utf8').toString('base64');
|
||||||
NODE_ENV,
|
|
||||||
JAMBONES_TIME_SERIES_HOST
|
function basicAuth(username, password) {
|
||||||
} = require('../config');
|
if (!username || !password) return {};
|
||||||
let alerter ;
|
const creds = `${username}:${password || ''}`;
|
||||||
|
const header = `Basic ${toBase64(creds)}`;
|
||||||
|
return {Authorization: header};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRelativeUrl(u) {
|
||||||
|
return typeof u === 'string' && u.startsWith('/');
|
||||||
|
}
|
||||||
|
|
||||||
function isAbsoluteUrl(u) {
|
function isAbsoluteUrl(u) {
|
||||||
return typeof u === 'string' &&
|
return typeof u === 'string' &&
|
||||||
@@ -12,42 +21,83 @@ function isAbsoluteUrl(u) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Requestor {
|
class Requestor {
|
||||||
constructor(logger, account_sid, hook, secret) {
|
constructor(logger, hook) {
|
||||||
assert(typeof hook === 'object');
|
assert(typeof hook === 'object');
|
||||||
|
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.url = hook.url;
|
this.url = hook.url;
|
||||||
this.method = hook.method || 'POST';
|
this.method = hook.method || 'POST';
|
||||||
|
this.authHeader = basicAuth(hook.username, hook.password);
|
||||||
|
|
||||||
|
const u = parseUrl(this.url);
|
||||||
|
const myPort = u.port ? `:${u.port}` : '';
|
||||||
|
const baseUrl = this._baseUrl = `${u.protocol}://${u.resource}${myPort}`;
|
||||||
|
|
||||||
|
this.get = bent(baseUrl, 'GET', 'buffer', 200, 201);
|
||||||
|
this.post = bent(baseUrl, 'POST', 'buffer', 200, 201);
|
||||||
|
|
||||||
this.username = hook.username;
|
this.username = hook.username;
|
||||||
this.password = hook.password;
|
this.password = hook.password;
|
||||||
this.secret = secret;
|
|
||||||
this.account_sid = account_sid;
|
|
||||||
|
|
||||||
assert(isAbsoluteUrl(this.url));
|
assert(isAbsoluteUrl(this.url));
|
||||||
assert(['GET', 'POST'].includes(this.method));
|
assert(['GET', 'POST'].includes(this.method));
|
||||||
|
|
||||||
const {stats} = require('../../').srf.locals;
|
const {stats} = require('../../').srf.locals;
|
||||||
this.stats = stats;
|
this.stats = stats;
|
||||||
|
|
||||||
if (!alerter) {
|
|
||||||
alerter = timeSeries(logger, {
|
|
||||||
host: JAMBONES_TIME_SERIES_HOST,
|
|
||||||
commitSize: 50,
|
|
||||||
commitInterval: 'test' === NODE_ENV ? 7 : 20
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get Alerter() {
|
get baseUrl() {
|
||||||
if (!alerter) {
|
return this._baseUrl;
|
||||||
alerter = timeSeries(this.logger, {
|
}
|
||||||
host: JAMBONES_TIME_SERIES_HOST,
|
|
||||||
commitSize: 50,
|
/**
|
||||||
commitInterval: 'test' === NODE_ENV ? 7 : 20
|
* Make an HTTP request.
|
||||||
});
|
* All requests use json bodies.
|
||||||
|
* All requests expect a 200 statusCode on success
|
||||||
|
* @param {object|string} hook - may be a absolute or relative url, or an object
|
||||||
|
* @param {string} [hook.url] - an absolute or relative url
|
||||||
|
* @param {string} [hook.method] - 'GET' or 'POST'
|
||||||
|
* @param {string} [hook.username] - if basic auth is protecting the endpoint
|
||||||
|
* @param {string} [hook.password] - if basic auth is protecting the endpoint
|
||||||
|
* @param {object} [params] - request parameters
|
||||||
|
*/
|
||||||
|
async request(hook, params) {
|
||||||
|
params = params || null;
|
||||||
|
const url = hook.url || hook;
|
||||||
|
const method = hook.method || 'POST';
|
||||||
|
const {username, password} = typeof hook === 'object' ? hook : {};
|
||||||
|
|
||||||
|
assert.ok(url, 'Requestor:request url was not provided');
|
||||||
|
assert.ok, (['GET', 'POST'].includes(method), `Requestor:request method must be 'GET' or 'POST' not ${method}`);
|
||||||
|
|
||||||
|
this.logger.debug({hook, params}, `Requestor:request ${method} ${url}`);
|
||||||
|
const startAt = process.hrtime();
|
||||||
|
|
||||||
|
let buf;
|
||||||
|
try {
|
||||||
|
buf = isRelativeUrl(url) ?
|
||||||
|
await this.post(url, params, this.authHeader) :
|
||||||
|
await bent(method, 'buffer', 200, 201, 202)(url, params, basicAuth(username, password));
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.info({baseUrl: this.baseUrl, url: err.statusCode},
|
||||||
|
`web callback returned unexpected error code ${err.statusCode}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const diff = process.hrtime(startAt);
|
||||||
|
const time = diff[0] * 1e3 + diff[1] * 1e-6;
|
||||||
|
const rtt = time.toFixed(0);
|
||||||
|
if (buf) this.stats.histogram('app.hook.response_time', rtt, ['hook_type:app']);
|
||||||
|
|
||||||
|
if (buf && buf.toString().length > 0) {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(buf.toString());
|
||||||
|
this.logger.info({response: json}, `Requestor:request ${method} ${url} succeeded in ${rtt}ms`);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
//this.logger.debug({err, url, method}, `Requestor:request returned non-JSON content: '${buf.toString()}'`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return alerter;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,31 @@
|
|||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
const uuidv4 = require('uuid-random');
|
const noopLogger = {info: () => {}, error: () => {}};
|
||||||
const {LifeCycleEvents, FS_UUID_SET_NAME} = require('./constants');
|
const {LifeCycleEvents} = require('./constants');
|
||||||
const Emitter = require('events');
|
const Emitter = require('events');
|
||||||
const debug = require('debug')('jambonz:feature-server');
|
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) => {
|
module.exports = (logger) => {
|
||||||
logger = logger || noopLogger;
|
logger = logger || noopLogger;
|
||||||
let idxSbc = 0;
|
let idxSbc = 0;
|
||||||
let sbcs = [];
|
|
||||||
|
|
||||||
if (JAMBONES_SBCS) {
|
assert.ok(process.env.JAMBONES_SBCS, 'missing JAMBONES_SBCS env var');
|
||||||
sbcs = JAMBONES_SBCS
|
const sbcs = process.env.JAMBONES_SBCS
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((sbc) => sbc.trim());
|
.map((sbc) => sbc.trim());
|
||||||
assert.ok(sbcs.length, 'JAMBONES_SBCS env var is empty or misconfigured');
|
assert.ok(sbcs.length, 'JAMBONES_SBCS env var is empty or misconfigured');
|
||||||
logger.info({sbcs}, 'SBC inventory');
|
logger.info({sbcs}, 'SBC inventory');
|
||||||
}
|
|
||||||
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
|
// listen for SNS lifecycle changes
|
||||||
let lifecycleEmitter = new Emitter();
|
let lifecycleEmitter = new Emitter();
|
||||||
let dryUpCalls = false;
|
let dryUpCalls = false;
|
||||||
if (AWS_SNS_TOPIC_ARM && AWS_REGION) {
|
if (process.env.AWS_SNS_TOPIC_ARM &&
|
||||||
|
process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY && process.env.AWS_REGION) {
|
||||||
|
|
||||||
(async function() {
|
(async function() {
|
||||||
try {
|
try {
|
||||||
lifecycleEmitter = await require('./aws-sns-lifecycle')(logger);
|
lifecycleEmitter = await require('./aws-sns-lifecycle')(logger);
|
||||||
|
|
||||||
lifecycleEmitter
|
lifecycleEmitter
|
||||||
.on('SubscriptionConfirmation', ({publicIp}) => {
|
|
||||||
const {srf} = require('../..');
|
|
||||||
srf.locals.publicIp = publicIp;
|
|
||||||
})
|
|
||||||
.on(LifeCycleEvents.ScaleIn, () => {
|
.on(LifeCycleEvents.ScaleIn, () => {
|
||||||
logger.info('AWS scale-in notification: begin drying up calls');
|
logger.info('AWS scale-in notification: begin drying up calls');
|
||||||
dryUpCalls = true;
|
dryUpCalls = true;
|
||||||
@@ -85,14 +65,9 @@ module.exports = (logger) => {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
else if (K8S) {
|
|
||||||
lifecycleEmitter.scaleIn = () => process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
// send OPTIONS pings to SBCs
|
||||||
async function pingProxies(srf) {
|
async function pingProxies(srf) {
|
||||||
if (NODE_ENV === 'test') return;
|
|
||||||
|
|
||||||
for (const sbc of sbcs) {
|
for (const sbc of sbcs) {
|
||||||
try {
|
try {
|
||||||
const ms = srf.locals.getFreeswitch();
|
const ms = srf.locals.getFreeswitch();
|
||||||
@@ -101,8 +76,7 @@ module.exports = (logger) => {
|
|||||||
method: 'OPTIONS',
|
method: 'OPTIONS',
|
||||||
headers: {
|
headers: {
|
||||||
'X-FS-Status': ms && !dryUpCalls ? 'open' : 'closed',
|
'X-FS-Status': ms && !dryUpCalls ? 'open' : 'closed',
|
||||||
'X-FS-Calls': srf.locals.sessionTracker.count,
|
'X-FS-Calls': srf.locals.sessionTracker.count
|
||||||
'X-FS-ServiceUrl': srf.locals.serviceUrl
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
req.on('response', (res) => {
|
req.on('response', (res) => {
|
||||||
@@ -113,46 +87,18 @@ module.exports = (logger) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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();
|
|
||||||
|
|
||||||
/* in case redis is restarted, re-insert our key every so often */
|
// OPTIONS ping the SBCs from each feature server every 60 seconds
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
// eslint-disable-next-line max-len
|
const {srf} = require('../..');
|
||||||
addToSet(FS_UUID_SET_NAME, uuid).catch((err) => logger.info({err}, `Error adding ${uuid} to set ${FS_UUID_SET_NAME}`));
|
pingProxies(srf);
|
||||||
}, 30000);
|
}, 20000);
|
||||||
// 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 {
|
|
||||||
// OPTIONS ping the SBCs from each feature server every 60 seconds
|
|
||||||
setInterval(() => {
|
|
||||||
const {srf} = require('../..');
|
|
||||||
pingProxies(srf);
|
|
||||||
}, OPTIONS_PING_INTERVAL);
|
|
||||||
|
|
||||||
// initial ping once we are up
|
// initial ping once we are up
|
||||||
setTimeout(async() => {
|
setTimeout(() => {
|
||||||
|
const {srf} = require('../..');
|
||||||
// if SBCs are auto-scaling, monitor them as they come and go
|
pingProxies(srf);
|
||||||
const {srf} = require('../..');
|
}, 1000);
|
||||||
if (!JAMBONES_SBCS) {
|
|
||||||
const {monitorSet} = srf.locals.dbHelpers;
|
|
||||||
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}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pingProxies(srf);
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lifecycleEmitter,
|
lifecycleEmitter,
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
const sdpTransform = require('sdp-transform');
|
|
||||||
|
|
||||||
const isOnhold = (sdp) => {
|
|
||||||
return sdp && (sdp.includes('a=sendonly') || sdp.includes('a=inactive'));
|
|
||||||
};
|
|
||||||
|
|
||||||
const mergeSdpMedia = (sdp1, sdp2) => {
|
|
||||||
const parsedSdp1 = sdpTransform.parse(sdp1);
|
|
||||||
const parsedSdp2 = sdpTransform.parse(sdp2);
|
|
||||||
|
|
||||||
parsedSdp1.media.push(...parsedSdp2.media);
|
|
||||||
return sdpTransform.write(parsedSdp1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCodecPlacement = (parsedSdp, codec) => parsedSdp?.media[0]?.rtp?.findIndex((e) => e.codec === codec);
|
|
||||||
|
|
||||||
const isOpusFirst = (sdp) => {
|
|
||||||
return getCodecPlacement(sdpTransform.parse(sdp), 'opus') === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const makeOpusFirst = (sdp) => {
|
|
||||||
const parsedSdp = sdpTransform.parse(sdp);
|
|
||||||
// Find the index of the OPUS codec
|
|
||||||
const opusIndex = getCodecPlacement(parsedSdp, 'opus');
|
|
||||||
|
|
||||||
// Move OPUS codec to the beginning
|
|
||||||
if (opusIndex > 0) {
|
|
||||||
const opusEntry = parsedSdp.media[0].rtp.splice(opusIndex, 1)[0];
|
|
||||||
parsedSdp.media[0].rtp.unshift(opusEntry);
|
|
||||||
|
|
||||||
// Also move the corresponding payload type in the "m" line
|
|
||||||
const opusPayloadType = parsedSdp.media[0].payloads.split(' ')[opusIndex];
|
|
||||||
const otherPayloadTypes = parsedSdp.media[0].payloads.split(' ').filter((pt) => pt != opusPayloadType);
|
|
||||||
parsedSdp.media[0].payloads = [opusPayloadType, ...otherPayloadTypes].join(' ');
|
|
||||||
}
|
|
||||||
return sdpTransform.write(parsedSdp);
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractSdpMedia = (sdp) => {
|
|
||||||
const parsedSdp1 = sdpTransform.parse(sdp);
|
|
||||||
if (parsedSdp1.media.length > 1) {
|
|
||||||
parsedSdp1.media = [parsedSdp1.media[0]];
|
|
||||||
const parsedSdp2 = sdpTransform.parse(sdp);
|
|
||||||
parsedSdp2.media = [parsedSdp2.media[1]];
|
|
||||||
|
|
||||||
return [sdpTransform.write(parsedSdp1), sdpTransform.write(parsedSdp2)];
|
|
||||||
} else {
|
|
||||||
return [sdp, sdp];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
isOnhold,
|
|
||||||
mergeSdpMedia,
|
|
||||||
extractSdpMedia,
|
|
||||||
isOpusFirst,
|
|
||||||
makeOpusFirst
|
|
||||||
};
|
|
||||||
13
lib/utils/select-sbc.js
Normal file
13
lib/utils/select-sbc.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const CIDRMatcher = require('cidr-matcher');
|
||||||
|
const matcher = new CIDRMatcher([process.env.JAMBONES_NETWORK_CIDR]);
|
||||||
|
|
||||||
|
module.exports = (sbcList) => {
|
||||||
|
const obj = sbcList
|
||||||
|
.split(',')
|
||||||
|
.map((str) => {
|
||||||
|
const arr = /^(.*)\/(.*):(\d+)$/.exec(str);
|
||||||
|
return {protocol: arr[1], host: arr[2], port: arr[3]};
|
||||||
|
})
|
||||||
|
.find((obj) => 'udp' == obj.protocol && matcher.contains(obj.host));
|
||||||
|
if (obj) return `${obj.host}:${obj.port}`;
|
||||||
|
};
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
const xmlParser = require('xml2js').parseString;
|
|
||||||
const uuidv4 = require('uuid-random');
|
|
||||||
const parseUri = require('drachtio-srf').parseUri;
|
|
||||||
const transform = require('sdp-transform');
|
|
||||||
const debug = require('debug')('jambonz:feature-server');
|
|
||||||
|
|
||||||
const parseCallData = (prefix, obj) => {
|
|
||||||
const ret = {};
|
|
||||||
const group = obj[`${prefix}group`];
|
|
||||||
if (group) {
|
|
||||||
const key = Object.keys(group[0]).find((k) => /:?callData$/.test(k));
|
|
||||||
//const o = _.find(group[0], (value, key) => /:?callData$/.test(key));
|
|
||||||
if (key) {
|
|
||||||
//const callData = o[0];
|
|
||||||
const callData = group[0][key];
|
|
||||||
for (const key of Object.keys(callData)) {
|
|
||||||
if (['fromhdr', 'tohdr', 'callid'].includes(key)) ret[key] = callData[key][0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
debug('parseCallData', prefix, obj, ret);
|
|
||||||
return ret;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* parse a SIPREC multiparty body
|
|
||||||
* @param {object} opts - options
|
|
||||||
* @return {Promise}
|
|
||||||
*/
|
|
||||||
const parseSiprecPayload = (req, logger) => {
|
|
||||||
const opts = {};
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let sdp, meta ;
|
|
||||||
for (let i = 0; i < req.payload.length; i++) {
|
|
||||||
switch (req.payload[i].type) {
|
|
||||||
case 'application/sdp':
|
|
||||||
sdp = req.payload[i].content ;
|
|
||||||
break ;
|
|
||||||
|
|
||||||
case 'application/rs-metadata+xml':
|
|
||||||
case 'application/rs-metadata':
|
|
||||||
meta = opts.xml = req.payload[i].content ;
|
|
||||||
break ;
|
|
||||||
|
|
||||||
default:
|
|
||||||
break ;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (!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) {
|
|
||||||
if (ps.hasOwnProperty(`${prefix}send`)) {
|
|
||||||
part.send = ps[`${prefix}send`][0];
|
|
||||||
}
|
|
||||||
if (ps.hasOwnProperty(`${prefix}recv`)) {
|
|
||||||
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 v of Object.values(participants)) {
|
|
||||||
if (v.send === streamId) {
|
|
||||||
sender = v;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
//const sender = _.find(participants, { 'send': streamId});
|
|
||||||
|
|
||||||
if (!sender) return;
|
|
||||||
|
|
||||||
sender.label = s[`${prefix}label`][0];
|
|
||||||
|
|
||||||
if (-1 !== ['1', 'a_leg', 'inbound', '10'].indexOf(sender.label)) {
|
|
||||||
opts.caller.aor = sender.aor;
|
|
||||||
if (sender.name) opts.caller.name = sender.name;
|
|
||||||
// Remap the sdp stream base on sender label
|
|
||||||
if (!opts.sdp1.includes(`a=label:${sender.label}`)) {
|
|
||||||
const tmp = opts.sdp1;
|
|
||||||
opts.sdp1 = opts.sdp2;
|
|
||||||
opts.sdp2 = tmp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 } ;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
const snakeCase = require('to-snake-case');
|
|
||||||
|
|
||||||
const isObject = (value) => typeof value === 'object' && value !== null;
|
|
||||||
|
|
||||||
const snakeObject = (obj, excludes) => {
|
|
||||||
if (Array.isArray(obj)) return obj.map((o) => {
|
|
||||||
return isObject(o) ? snakeObject(o, excludes) : o;
|
|
||||||
});
|
|
||||||
|
|
||||||
const target = {};
|
|
||||||
for (const [key, value] of Object.entries(obj)) {
|
|
||||||
if (excludes.includes(key)) {
|
|
||||||
target[key] = value;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const newKey = snakeCase(key);
|
|
||||||
const newValue = isObject(value) ? snakeObject(value, excludes) : value;
|
|
||||||
target[newKey] = newValue;
|
|
||||||
}
|
|
||||||
return target;
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = (obj, excludes = []) => {
|
|
||||||
return snakeObject(obj, excludes);
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
const sdpTransform = require('sdp-transform');
|
|
||||||
|
|
||||||
const stripCodecs = (logger, remoteSdp, localSdp) => {
|
|
||||||
try {
|
|
||||||
const sdp = sdpTransform.parse(remoteSdp);
|
|
||||||
const local = sdpTransform.parse(localSdp);
|
|
||||||
const m = local.media
|
|
||||||
.find((m) => 'audio' === m.type);
|
|
||||||
const pt = m.rtp[0].payload;
|
|
||||||
|
|
||||||
/* manipulate on the audio section */
|
|
||||||
const audio = sdp.media.find((m) => 'audio' === m.type);
|
|
||||||
|
|
||||||
/* discard all of the codecs except the first in our 200 OK, and telephony-events */
|
|
||||||
const ptSaves = audio.rtp
|
|
||||||
.filter((r) => r.codec === 'telephone-event' || r.payload === pt)
|
|
||||||
.map((r) => r.payload);
|
|
||||||
const rtp = audio.rtp.filter((r) => ptSaves.includes(r.payload));
|
|
||||||
|
|
||||||
/* reattach the new rtp sections and stripped payload list */
|
|
||||||
audio.rtp = rtp;
|
|
||||||
audio.payloads = rtp.map((r) => r.payload).join(' ');
|
|
||||||
return sdpTransform.write(sdp);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({err, remoteSdp, localSdp}, 'strip-ancillary-codecs error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = stripCodecs;
|
|
||||||
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
module.exports = function(tasks) {
|
module.exports = function(tasks) {
|
||||||
return `[${tasks.map((t) => t.summary).join(',')}]`;
|
return `[${tasks.map((t) => t.name).join(',')}]`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,790 +0,0 @@
|
|||||||
const {
|
|
||||||
TaskName,
|
|
||||||
} = require('./constants.json');
|
|
||||||
|
|
||||||
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',
|
|
||||||
'AZURE_SPEECH_SEGMENTATION_SILENCE_TIMEOUT_MS'
|
|
||||||
],
|
|
||||||
deepgram: [
|
|
||||||
'DEEPGRAM_SPEECH_KEYWORDS',
|
|
||||||
'DEEPGRAM_API_KEY',
|
|
||||||
'DEEPGRAM_SPEECH_TIER',
|
|
||||||
'DEEPGRAM_SPEECH_MODEL',
|
|
||||||
'DEEPGRAM_SPEECH_ENABLE_SMART_FORMAT',
|
|
||||||
'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_UTTERANCE_END_MS',
|
|
||||||
'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'
|
|
||||||
],
|
|
||||||
cobalt: [
|
|
||||||
'COBALT_SPEECH_HINTS',
|
|
||||||
'COBALT_COMPILED_CONTEXT_DATA',
|
|
||||||
'COBALT_METADATA'
|
|
||||||
],
|
|
||||||
soniox: [
|
|
||||||
'SONIOX_PROFANITY_FILTER',
|
|
||||||
'SONIOX_MODEL'
|
|
||||||
],
|
|
||||||
assemblyai: [
|
|
||||||
'ASSEMBLYAI_API_KEY',
|
|
||||||
'ASSEMBLYAI_WORD_BOOST'
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const consolidateTranscripts = (bufferedTranscripts, channel, language) => {
|
|
||||||
if (bufferedTranscripts.length === 1) return bufferedTranscripts[0];
|
|
||||||
let totalConfidence = 0;
|
|
||||||
const finalTranscript = bufferedTranscripts.reduce((acc, evt) => {
|
|
||||||
totalConfidence += evt.alternatives[0].confidence;
|
|
||||||
|
|
||||||
let newTranscript = evt.alternatives[0].transcript;
|
|
||||||
|
|
||||||
// If new transcript consists only of digits, spaces, and a trailing comma or period
|
|
||||||
if (newTranscript.match(/^[\d\s]+[,.]?$/)) {
|
|
||||||
newTranscript = newTranscript.replace(/\s/g, ''); // Remove all spaces
|
|
||||||
if (newTranscript.endsWith(',')) {
|
|
||||||
newTranscript = newTranscript.slice(0, -1); // Remove the trailing comma
|
|
||||||
} else if (newTranscript.endsWith('.')) {
|
|
||||||
newTranscript = newTranscript.slice(0, -1); // Remove the trailing period
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastChar = acc.alternatives[0].transcript.slice(-1);
|
|
||||||
const firstChar = newTranscript.charAt(0);
|
|
||||||
|
|
||||||
if (lastChar.match(/\d/) && firstChar.match(/\d/)) {
|
|
||||||
acc.alternatives[0].transcript += newTranscript;
|
|
||||||
} else {
|
|
||||||
acc.alternatives[0].transcript += ` ${newTranscript}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, {
|
|
||||||
language_code: language,
|
|
||||||
channel_tag: channel,
|
|
||||||
is_final: true,
|
|
||||||
alternatives: [{
|
|
||||||
transcript: ''
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
finalTranscript.alternatives[0].confidence = bufferedTranscripts.length === 1 ?
|
|
||||||
bufferedTranscripts[0].alternatives[0].confidence :
|
|
||||||
totalConfidence / bufferedTranscripts.length;
|
|
||||||
finalTranscript.alternatives[0].transcript = finalTranscript.alternatives[0].transcript.trim();
|
|
||||||
finalTranscript.vendor = {
|
|
||||||
name: 'deepgram',
|
|
||||||
evt: bufferedTranscripts
|
|
||||||
};
|
|
||||||
return finalTranscript;
|
|
||||||
};
|
|
||||||
|
|
||||||
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, shortUtterance) => {
|
|
||||||
const copy = JSON.parse(JSON.stringify(evt));
|
|
||||||
const alternatives = (evt.channel?.alternatives || [])
|
|
||||||
.map((alt) => ({
|
|
||||||
confidence: alt.confidence,
|
|
||||||
transcript: alt.transcript,
|
|
||||||
}));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* note difference between is_final and speech_final in Deepgram:
|
|
||||||
* https://developers.deepgram.com/docs/understand-endpointing-interim-results
|
|
||||||
*/
|
|
||||||
return {
|
|
||||||
language_code: language,
|
|
||||||
channel_tag: channel,
|
|
||||||
is_final: shortUtterance ? evt.is_final : evt.speech_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 normalizeCobalt = (evt, channel, language) => {
|
|
||||||
const copy = JSON.parse(JSON.stringify(evt));
|
|
||||||
const alternatives = (evt.alternatives || [])
|
|
||||||
.map((alt) => ({
|
|
||||||
confidence: alt.confidence,
|
|
||||||
transcript: alt.transcript_formatted,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
language_code: language,
|
|
||||||
channel_tag: channel,
|
|
||||||
is_final: evt.is_final,
|
|
||||||
alternatives,
|
|
||||||
vendor: {
|
|
||||||
name: 'cobalt',
|
|
||||||
evt: copy
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeCustom = (evt, channel, language, vendor) => {
|
|
||||||
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: vendor,
|
|
||||||
evt: copy
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeNuance = (evt, channel, language) => {
|
|
||||||
const copy = JSON.parse(JSON.stringify(evt));
|
|
||||||
return {
|
|
||||||
language_code: language,
|
|
||||||
channel_tag: channel,
|
|
||||||
is_final: evt.is_final,
|
|
||||||
alternatives: [evt.alternatives[0]],
|
|
||||||
vendor: {
|
|
||||||
name: 'nuance',
|
|
||||||
evt: copy
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeMicrosoft = (evt, channel, language, punctuation = true) => {
|
|
||||||
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,
|
|
||||||
// remove all puntuation if needed
|
|
||||||
transcript: punctuation ? n.Display : n.Display.replace(/\p{P}/gu, '')
|
|
||||||
};
|
|
||||||
}) :
|
|
||||||
[
|
|
||||||
{
|
|
||||||
transcript: punctuation ? evt.DisplayText || evt.Text : (evt.DisplayText || evt.Text).replace(/\p{P}/gu, '')
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeAssemblyAi = (evt, channel, language) => {
|
|
||||||
const copy = JSON.parse(JSON.stringify(evt));
|
|
||||||
return {
|
|
||||||
language_code: language,
|
|
||||||
channel_tag: channel,
|
|
||||||
is_final: evt.message_type === 'FinalTranscript',
|
|
||||||
alternatives: [
|
|
||||||
{
|
|
||||||
confidence: evt.confidence,
|
|
||||||
transcript: evt.text,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
vendor: {
|
|
||||||
name: 'ASSEMBLYAI',
|
|
||||||
evt: copy
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = (logger) => {
|
|
||||||
const normalizeTranscription = (evt, vendor, channel, language, shortUtterance, punctuation) => {
|
|
||||||
|
|
||||||
//logger.debug({ evt, vendor, channel, language }, 'normalizeTranscription');
|
|
||||||
switch (vendor) {
|
|
||||||
case 'deepgram':
|
|
||||||
return normalizeDeepgram(evt, channel, language, shortUtterance);
|
|
||||||
case 'microsoft':
|
|
||||||
return normalizeMicrosoft(evt, channel, language, punctuation);
|
|
||||||
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);
|
|
||||||
case 'cobalt':
|
|
||||||
return normalizeCobalt(evt, channel, language);
|
|
||||||
case 'assemblyai':
|
|
||||||
return normalizeAssemblyAi(evt, channel, language, shortUtterance);
|
|
||||||
default:
|
|
||||||
if (vendor.startsWith('custom:')) {
|
|
||||||
return normalizeCustom(evt, channel, language, vendor);
|
|
||||||
}
|
|
||||||
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}),
|
|
||||||
// When altLanguages is emptylist, we have to send value to freeswitch to clear the previous settings
|
|
||||||
...(rOpts.altLanguages &&
|
|
||||||
{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) {
|
|
||||||
const {azureOptions = {}} = rOpts;
|
|
||||||
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(',')}),
|
|
||||||
// When altLanguages is emptylist, we have to send value to freeswitch to clear the previous settings
|
|
||||||
...(rOpts.altLanguages &&
|
|
||||||
{AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES: [...new Set(rOpts.altLanguages)].join(',')}),
|
|
||||||
...(rOpts.requestSnr && {AZURE_REQUEST_SNR: 1}),
|
|
||||||
...(rOpts.profanityOption && {AZURE_PROFANITY_OPTION: rOpts.profanityOption}),
|
|
||||||
...(sttCredentials.use_custom_stt && sttCredentials.custom_stt_endpoint_url &&
|
|
||||||
{AZURE_SERVICE_ENDPOINT: sttCredentials.custom_stt_endpoint_url}),
|
|
||||||
...(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},
|
|
||||||
...(azureOptions.speechSegmentationSilenceTimeoutMs &&
|
|
||||||
{AZURE_SPEECH_SEGMENTATION_SILENCE_TIMEOUT_MS: azureOptions.speechSegmentationSilenceTimeoutMs}),
|
|
||||||
...(sttCredentials && {
|
|
||||||
...(sttCredentials.api_key && {AZURE_SUBSCRIPTION_KEY: sttCredentials.api_key}),
|
|
||||||
...(sttCredentials.region && {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.smartFormatting) &&
|
|
||||||
{DEEPGRAM_SPEECH_ENABLE_SMART_FORMAT: 1},
|
|
||||||
...(deepgramOptions.profanityFilter) &&
|
|
||||||
{DEEPGRAM_SPEECH_PROFANITY_FILTER: 1},
|
|
||||||
...(deepgramOptions.redact) &&
|
|
||||||
{DEEPGRAM_SPEECH_REDACT: deepgramOptions.redact},
|
|
||||||
...(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 && rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'string' &&
|
|
||||||
{DEEPGRAM_SPEECH_KEYWORDS: rOpts.hints.map((h) => h.trim()).join(',')}),
|
|
||||||
...(rOpts.hints && 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 === false ? 'false' : deepgramOptions.endpointing},
|
|
||||||
...(deepgramOptions.utteranceEndMs) &&
|
|
||||||
{DEEPGRAM_SPEECH_UTTERANCE_END_MS: deepgramOptions.utteranceEndMs},
|
|
||||||
...(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 ('cobalt' === vendor) {
|
|
||||||
const {cobaltOptions = {}} = rOpts;
|
|
||||||
const cobaltUri = cobaltOptions.serverUri || sttCredentials.cobalt_server_uri;
|
|
||||||
opts = {
|
|
||||||
...opts,
|
|
||||||
...(rOpts.words && {COBALT_WORD_TIME_OFFSETS: 1}),
|
|
||||||
...(!rOpts.words && {COBALT_WORD_TIME_OFFSETS: 0}),
|
|
||||||
...(rOpts.model && {COBALT_MODEL: rOpts.model}),
|
|
||||||
...(cobaltUri && {COBALT_SERVER_URI: cobaltUri}),
|
|
||||||
...(rOpts.hints?.length > 0 && typeof rOpts.hints[0] === 'string' &&
|
|
||||||
{COBALT_SPEECH_HINTS: rOpts.hints.join(',')}),
|
|
||||||
...(rOpts.hints?.length > 0 && typeof rOpts.hints[0] === 'object' &&
|
|
||||||
{COBALT_SPEECH_HINTS: JSON.stringify(rOpts.hints)}),
|
|
||||||
...(rOpts.hints?.length > 0 &&
|
|
||||||
{COBALT_CONTEXT_TOKEN: cobaltOptions.contextToken || 'unk:default'}),
|
|
||||||
...(cobaltOptions.metadata && {COBALT_METADATA: cobaltOptions.metadata}),
|
|
||||||
...(cobaltOptions.enableConfusionNetwork && {COBALT_ENABLE_CONFUSION_NETWORK: 1}),
|
|
||||||
...(cobaltOptions.compiledContextData && {COBALT_COMPILED_CONTEXT_DATA: cobaltOptions.compiledContextData}),
|
|
||||||
};
|
|
||||||
} else if ('assemblyai' === vendor) {
|
|
||||||
opts = {
|
|
||||||
...opts,
|
|
||||||
...(sttCredentials.api_key) &&
|
|
||||||
{ASSEMBLYAI_API_KEY: sttCredentials.api_key},
|
|
||||||
...(rOpts.hints?.length > 0 &&
|
|
||||||
{ASSEMBLYAI_WORD_BOOST: JSON.stringify(rOpts.hints)})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
...(auth_token && {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 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 === 'cobalt') {
|
|
||||||
const {serverUri} = recognizer.cobaltOptions || {};
|
|
||||||
if (serverUri) return {cobalt_server_uri: serverUri};
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
setSpeechCredentialsAtRuntime,
|
|
||||||
compileSonioxTranscripts,
|
|
||||||
consolidateTranscripts
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,388 +0,0 @@
|
|||||||
const assert = require('assert');
|
|
||||||
const BaseRequestor = require('./base-requestor');
|
|
||||||
const short = require('short-uuid');
|
|
||||||
const {HookMsgTypes} = require('./constants.json');
|
|
||||||
const Websocket = require('ws');
|
|
||||||
const snakeCaseKeys = require('./snakecase-keys');
|
|
||||||
const {
|
|
||||||
RESPONSE_TIMEOUT_MS,
|
|
||||||
JAMBONES_WS_PING_INTERVAL_MS,
|
|
||||||
MAX_RECONNECTS,
|
|
||||||
JAMBONES_WS_HANDSHAKE_TIMEOUT_MS,
|
|
||||||
JAMBONES_WS_MAX_PAYLOAD
|
|
||||||
} = require('../config');
|
|
||||||
|
|
||||||
class WsRequestor extends BaseRequestor {
|
|
||||||
constructor(logger, account_sid, hook, secret) {
|
|
||||||
super(logger, account_sid, hook, secret);
|
|
||||||
this.connections = 0;
|
|
||||||
this.messagesInFlight = new Map();
|
|
||||||
this.maliciousClient = false;
|
|
||||||
this.closedGracefully = false;
|
|
||||||
this.backoffMs = 500;
|
|
||||||
this.connectInProgress = false;
|
|
||||||
this.queuedMsg = [];
|
|
||||||
this.id = short.generate();
|
|
||||||
|
|
||||||
assert(this._isAbsoluteUrl(this.url));
|
|
||||||
|
|
||||||
this.on('socket-closed', this._onSocketClosed.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a JSON payload over the websocket. If this is the first request,
|
|
||||||
* open the websocket.
|
|
||||||
* All requests expect an ack message in response
|
|
||||||
* @param {object|string} hook - may be a absolute or relative url, or an object
|
|
||||||
* @param {string} [hook.url] - an absolute or relative url
|
|
||||||
* @param {string} [hook.method] - 'GET' or 'POST'
|
|
||||||
* @param {string} [hook.username] - if basic auth is protecting the endpoint
|
|
||||||
* @param {string} [hook.password] - if basic auth is protecting the endpoint
|
|
||||||
* @param {object} [params] - request parameters
|
|
||||||
*/
|
|
||||||
async request(type, hook, params, httpHeaders = {}) {
|
|
||||||
assert(HookMsgTypes.includes(type));
|
|
||||||
const url = hook.url || hook;
|
|
||||||
const wantsAck = !['call:status', 'verb:status', 'jambonz:error'].includes(type);
|
|
||||||
|
|
||||||
if (this.maliciousClient) {
|
|
||||||
this.logger.info({url: this.url}, 'WsRequestor:request - discarding msg to malicious client');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.closedGracefully) {
|
|
||||||
this.logger.debug(`WsRequestor:request - discarding ${type} because socket was closed gracefully`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'session:new') this.call_sid = params.callSid;
|
|
||||||
|
|
||||||
/* if we have an absolute url, and it is http then do a standard webhook */
|
|
||||||
if (this._isAbsoluteUrl(url) && url.startsWith('http')) {
|
|
||||||
const HttpRequestor = require('./http-requestor');
|
|
||||||
this.logger.debug({hook}, 'WsRequestor: sending a webhook (HTTP)');
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* connect if necessary */
|
|
||||||
if (!this.ws) {
|
|
||||||
if (this.connectInProgress) {
|
|
||||||
this.logger.debug(
|
|
||||||
`WsRequestor:request(${this.id}) - queueing ${type} message since we are connecting`);
|
|
||||||
if (wantsAck) {
|
|
||||||
const p = new Promise((resolve, reject) => {
|
|
||||||
this.queuedMsg.push({type, hook, params, httpHeaders, promise: {resolve, reject}});
|
|
||||||
});
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.queuedMsg.push({type, hook, params, httpHeaders});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.connectInProgress = true;
|
|
||||||
this.logger.debug(`WsRequestor:request(${this.id}) - connecting since we do not have a connection for ${type}`);
|
|
||||||
if (this.connections >= MAX_RECONNECTS) {
|
|
||||||
return Promise.reject(`max attempts connecting to ${this.url}`);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const startAt = process.hrtime();
|
|
||||||
await this._connect();
|
|
||||||
const rtt = this._roundTrip(startAt);
|
|
||||||
this.stats.histogram('app.hook.connect_time', rtt, ['hook_type:app']);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info({url, err}, 'WsRequestor:request - failed connecting');
|
|
||||||
this.connectInProgress = false;
|
|
||||||
return Promise.reject(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert(this.ws);
|
|
||||||
|
|
||||||
/* prepare and send message */
|
|
||||||
let payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null;
|
|
||||||
if (type === 'session:new') this._sessionData = payload;
|
|
||||||
if (type === 'session:reconnect') payload = this._sessionData;
|
|
||||||
assert.ok(url, 'WsRequestor:request url was not provided');
|
|
||||||
|
|
||||||
const msgid = short.generate();
|
|
||||||
// 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,
|
|
||||||
msgid,
|
|
||||||
call_sid: this.call_sid,
|
|
||||||
hook: type === 'verb:hook' ? url : undefined,
|
|
||||||
data: {...payload},
|
|
||||||
...b3
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendQueuedMsgs = () => {
|
|
||||||
if (this.queuedMsg.length > 0) {
|
|
||||||
for (const {type, hook, params, httpHeaders, promise} of this.queuedMsg) {
|
|
||||||
this.logger.debug(`WsRequestor:request - preparing queued ${type} for sending`);
|
|
||||||
if (promise) {
|
|
||||||
this.request(type, hook, params, httpHeaders)
|
|
||||||
.then((res) => promise.resolve(res))
|
|
||||||
.catch((err) => promise.reject(err));
|
|
||||||
}
|
|
||||||
else setImmediate(this.request.bind(this, type, hook, params, httpHeaders));
|
|
||||||
}
|
|
||||||
this.queuedMsg.length = 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
//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 (!wantsAck || reconnectingWithoutAck) {
|
|
||||||
this.ws?.send(JSON.stringify(obj), () => {
|
|
||||||
this.logger.debug({obj}, `WsRequestor:request websocket: sent (${url})`);
|
|
||||||
sendQueuedMsgs();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* messages that require an ack */
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
/* give the far end a reasonable amount of time to ack our message */
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
const {failure} = this.messagesInFlight.get(msgid) || {};
|
|
||||||
failure && failure(`timeout from far end for msgid ${msgid}`);
|
|
||||||
this.messagesInFlight.delete(msgid);
|
|
||||||
}, RESPONSE_TIMEOUT_MS);
|
|
||||||
|
|
||||||
/* save the message info for reply */
|
|
||||||
const startAt = process.hrtime();
|
|
||||||
this.messagesInFlight.set(msgid, {
|
|
||||||
timer,
|
|
||||||
success: (response) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
const rtt = this._roundTrip(startAt);
|
|
||||||
this.logger.debug({response}, `WsRequestor:request ${url} succeeded in ${rtt}ms`);
|
|
||||||
this.stats.histogram('app.hook.ws_response_time', rtt, ['hook_type:app']);
|
|
||||||
resolve(response);
|
|
||||||
},
|
|
||||||
failure: (err) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/* send the message */
|
|
||||||
this.ws.send(JSON.stringify(obj), () => {
|
|
||||||
this.logger.debug({obj}, `WsRequestor:request websocket: sent (${url})`);
|
|
||||||
sendQueuedMsgs();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_stopPingTimer() {
|
|
||||||
if (this._pingTimer) {
|
|
||||||
clearInterval(this._pingTimer);
|
|
||||||
this._pingTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.closedGracefully = true;
|
|
||||||
this.logger.debug('WsRequestor:close closing socket');
|
|
||||||
this._stopPingTimer();
|
|
||||||
try {
|
|
||||||
if (this.ws) {
|
|
||||||
this.ws.close(1000);
|
|
||||||
this.ws.removeAllListeners();
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
this._clearPendingMessages();
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info({err}, 'WsRequestor: Error closing socket');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_connect() {
|
|
||||||
assert(!this.ws);
|
|
||||||
this._stopPingTimer();
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const handshakeTimeout = JAMBONES_WS_HANDSHAKE_TIMEOUT_MS ?
|
|
||||||
parseInt(JAMBONES_WS_HANDSHAKE_TIMEOUT_MS) :
|
|
||||||
1500;
|
|
||||||
let opts = {
|
|
||||||
followRedirects: true,
|
|
||||||
maxRedirects: 2,
|
|
||||||
handshakeTimeout,
|
|
||||||
maxPayload: JAMBONES_WS_MAX_PAYLOAD ? parseInt(JAMBONES_WS_MAX_PAYLOAD) : 24 * 1024,
|
|
||||||
};
|
|
||||||
if (this.username && this.password) opts = {...opts, auth: `${this.username}:${this.password}`};
|
|
||||||
|
|
||||||
this
|
|
||||||
.once('ready', (ws) => {
|
|
||||||
this.removeAllListeners('not-ready');
|
|
||||||
if (this.connections > 1) this.request('session:reconnect', this.url);
|
|
||||||
resolve();
|
|
||||||
})
|
|
||||||
.once('not-ready', (err) => {
|
|
||||||
this.removeAllListeners('ready');
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
const ws = new Websocket(this.url, ['ws.jambonz.org'], opts);
|
|
||||||
this._setHandlers(ws);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_setHandlers(ws) {
|
|
||||||
ws
|
|
||||||
.once('open', this._onOpen.bind(this, ws))
|
|
||||||
.once('close', this._onClose.bind(this))
|
|
||||||
.on('message', this._onMessage.bind(this))
|
|
||||||
.once('unexpected-response', this._onUnexpectedResponse.bind(this, ws))
|
|
||||||
.on('error', this._onError.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
_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');
|
|
||||||
}
|
|
||||||
else this.emit('not-ready', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onOpen(ws) {
|
|
||||||
this.logger.info({url: this.url}, `WsRequestor(${this.id}) - successfully connected`);
|
|
||||||
if (this.ws) this.logger.info({old_ws: this.ws._socket.address()}, 'WsRequestor:_onOpen');
|
|
||||||
assert(!this.ws);
|
|
||||||
this.ws = ws;
|
|
||||||
this.connectInProgress = false;
|
|
||||||
this.connections++;
|
|
||||||
this.emit('ready', ws);
|
|
||||||
|
|
||||||
if (JAMBONES_WS_PING_INTERVAL_MS > 15000) {
|
|
||||||
this._pingTimer = setInterval(() => this.ws?.ping(), JAMBONES_WS_PING_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_onClose(code) {
|
|
||||||
this.logger.info(`WsRequestor(${this.id}) - closed from far end ${code}`);
|
|
||||||
this._stopPingTimer();
|
|
||||||
if (this.connections > 0 && code !== 1000) {
|
|
||||||
this.logger.info({url: this.url}, 'WsRequestor - socket closed unexpectedly from remote side');
|
|
||||||
this.emit('socket-closed');
|
|
||||||
}
|
|
||||||
else if (code === 1000) this.closedGracefully = true;
|
|
||||||
this.ws?.removeAllListeners();
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_onUnexpectedResponse(ws, req, res) {
|
|
||||||
assert(!this.ws);
|
|
||||||
this.logger.info({
|
|
||||||
headers: res.headers,
|
|
||||||
statusCode: res.statusCode,
|
|
||||||
statusMessage: res.statusMessage
|
|
||||||
}, 'WsRequestor - unexpected response');
|
|
||||||
this.emit('connection-failure');
|
|
||||||
this.emit('not-ready', new Error(`${res.statusCode} ${res.statusMessage}`));
|
|
||||||
this.connections++;
|
|
||||||
}
|
|
||||||
|
|
||||||
_onSocketClosed() {
|
|
||||||
this.ws = null;
|
|
||||||
this.emit('connection-dropped');
|
|
||||||
this._stopPingTimer();
|
|
||||||
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(
|
|
||||||
{haveWs: !!this.ws, connectInProgress: this.connectInProgress},
|
|
||||||
'WsRequestor:_onSocketClosed time to reconnect');
|
|
||||||
if (!this.ws && !this.connectInProgress) {
|
|
||||||
this.connectInProgress = true;
|
|
||||||
this._connect().catch((err) => this.connectInProgress = false);
|
|
||||||
}
|
|
||||||
}, this.backoffMs);
|
|
||||||
this.backoffMs = this.backoffMs < 2000 ? this.backoffMs * 2 : (this.backoffMs + 2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_onMessage(content, isBinary) {
|
|
||||||
if (this.isBinary) {
|
|
||||||
this.logger.info({url: this.url}, 'WsRequestor:_onMessage - discarding binary message');
|
|
||||||
this.maliciousClient = true;
|
|
||||||
this.ws.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* messages must be JSON format */
|
|
||||||
try {
|
|
||||||
const obj = JSON.parse(content);
|
|
||||||
const {type, msgid, command, call_sid = this.call_sid, queueCommand = false, data} = obj;
|
|
||||||
|
|
||||||
//this.logger.debug({obj}, 'WsRequestor:request websocket: received');
|
|
||||||
assert.ok(type, 'type property not supplied');
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'ack':
|
|
||||||
assert.ok(msgid, 'msgid not supplied');
|
|
||||||
this._recvAck(msgid, data);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'command':
|
|
||||||
assert.ok(command, 'command property not supplied');
|
|
||||||
assert.ok(data, 'data property not supplied');
|
|
||||||
this._recvCommand(msgid, command, call_sid, queueCommand, data);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
assert.ok(false, `invalid type property: ${type}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info({err, content}, 'WsRequestor:_onMessage - invalid incoming message');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_recvAck(msgid, data) {
|
|
||||||
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.messagesInFlight.delete(msgid);
|
|
||||||
const {success} = obj;
|
|
||||||
success && success(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
_recvCommand(msgid, command, call_sid, queueCommand, data) {
|
|
||||||
// TODO: validate command
|
|
||||||
this.logger.debug({msgid, command, call_sid, queueCommand, data}, 'received command');
|
|
||||||
this.emit('command', {msgid, command, call_sid, queueCommand, data});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = WsRequestor;
|
|
||||||
21417
package-lock.json
generated
21417
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
80
package.json
80
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jambonz-feature-server",
|
"name": "jambonz-feature-server",
|
||||||
"version": "0.8.5",
|
"version": "0.2.4",
|
||||||
"main": "app.js",
|
"main": "app.js",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.16.0"
|
"node": ">= 10.16.0"
|
||||||
@@ -16,66 +16,40 @@
|
|||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/jambonz/jambonz-feature-server.git"
|
"url": "https://github.com/jambonz/jambonz-feature-server.git"
|
||||||
},
|
},
|
||||||
"bugs": {},
|
"bugs": {
|
||||||
|
"url": "https://github.com/jambonz/jambonz-feature-server/issues"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node app",
|
"start": "node app",
|
||||||
"test": "NODE_ENV=test JAMBONES_HOSTING=1 HTTP_POOL=1 JAMBONES_TTS_TRIM_SILENCE=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/ ",
|
"test": "NODE_ENV=test JAMBONES_NETWORK_CIDR=127.0.0.1/32 node test/ | ./node_modules/.bin/tap-spec",
|
||||||
"coverage": "./node_modules/.bin/nyc --reporter html --report-dir ./coverage npm run test",
|
"coverage": "./node_modules/.bin/nyc --reporter html --report-dir ./coverage npm run test",
|
||||||
"jslint": "eslint app.js tracer.js lib",
|
"jslint": "eslint app.js lib"
|
||||||
"jslint:fix": "eslint app.js tracer.js lib --fix"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-auto-scaling": "^3.360.0",
|
"@jambonz/db-helpers": "^0.4.2",
|
||||||
"@aws-sdk/client-sns": "^3.360.0",
|
"@jambonz/realtimedb-helpers": "^0.2.16",
|
||||||
"@jambonz/db-helpers": "^0.9.1",
|
"@jambonz/stats-collector": "^0.0.4",
|
||||||
"@jambonz/http-health-check": "^0.0.1",
|
"bent": "^7.3.9",
|
||||||
"@jambonz/mw-registrar": "^0.2.4",
|
"cidr-matcher": "^2.1.1",
|
||||||
"@jambonz/realtimedb-helpers": "^0.8.7",
|
"debug": "^4.1.1",
|
||||||
"@jambonz/speech-utils": "^0.0.33",
|
|
||||||
"@jambonz/stats-collector": "^0.1.9",
|
|
||||||
"@jambonz/time-series": "^0.2.8",
|
|
||||||
"@jambonz/verb-specifications": "^0.0.50",
|
|
||||||
"@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",
|
|
||||||
"bent": "^7.3.12",
|
|
||||||
"debug": "^4.3.4",
|
|
||||||
"deepcopy": "^2.1.0",
|
"deepcopy": "^2.1.0",
|
||||||
"drachtio-fsmrf": "^3.0.33",
|
"drachtio-fsmrf": "^2.0.1",
|
||||||
"drachtio-srf": "^4.5.31",
|
"drachtio-srf": "^4.4.39",
|
||||||
"express": "^4.18.2",
|
"express": "^4.17.1",
|
||||||
"express-validator": "^7.0.1",
|
"ip": "^1.1.5",
|
||||||
"ip": "^1.1.8",
|
"jambonz-mw-registrar": "^0.1.3",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.27.0",
|
||||||
"parse-url": "^8.1.0",
|
"parse-url": "^5.0.2",
|
||||||
"pino": "^8.8.0",
|
"pino": "^6.5.1",
|
||||||
"polly-ssml-split": "^0.1.0",
|
"verify-aws-sns-signature": "^0.0.6",
|
||||||
"proxyquire": "^2.1.3",
|
"xml2js": "^0.4.23"
|
||||||
"sdp-transform": "^2.14.1",
|
|
||||||
"short-uuid": "^4.2.2",
|
|
||||||
"sinon": "^15.0.1",
|
|
||||||
"to-snake-case": "^1.0.0",
|
|
||||||
"undici": "^5.26.2",
|
|
||||||
"uuid-random": "^1.3.2",
|
|
||||||
"verify-aws-sns-signature": "^0.1.0",
|
|
||||||
"ws": "^8.9.0",
|
|
||||||
"xml2js": "^0.6.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"clear-module": "^4.1.2",
|
"blue-tape": "^1.0.0",
|
||||||
"eslint": "^7.32.0",
|
"clear-module": "^4.1.1",
|
||||||
"eslint-plugin-promise": "^4.3.1",
|
"eslint": "^7.7.0",
|
||||||
|
"eslint-plugin-promise": "^4.2.1",
|
||||||
"nyc": "^15.1.0",
|
"nyc": "^15.1.0",
|
||||||
"tape": "^5.6.1"
|
"tap-spec": "^5.0.0"
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"bufferutil": "^4.0.6",
|
|
||||||
"utf-8-validate": "^5.0.8"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
const test = require('tape');
|
|
||||||
const { sippUac } = require('./sipp')('test_fs');
|
|
||||||
|
|
||||||
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('account validation tests', async(t) => {
|
|
||||||
const {srf, disconnect} = require('../app');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connect(srf);
|
|
||||||
await sippUac('uac-expect-500.xml', '172.38.0.10');
|
|
||||||
t.pass('rejected INVITE without X-Account-Sid header');
|
|
||||||
await sippUac('uac-invalid-account-expect-503.xml', '172.38.0.10');
|
|
||||||
t.pass('rejected INVITE with invalid X-Account-Sid header');
|
|
||||||
await sippUac('uac-inactive-account-expect-503.xml', '172.38.0.10');
|
|
||||||
t.pass('rejected INVITE from inactive account');
|
|
||||||
|
|
||||||
disconnect();
|
|
||||||
} catch (err) {
|
|
||||||
console.log(`error received: ${err}`);
|
|
||||||
disconnect();
|
|
||||||
t.error(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
const test = require('tape');
|
|
||||||
const { sippUac } = require('./sipp')('test_fs');
|
|
||||||
const bent = require('bent');
|
|
||||||
const getJSON = bent('json')
|
|
||||||
const clearModule = require('clear-module');
|
|
||||||
const {provisionCallHook} = require('./utils')
|
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason, p) => {
|
|
||||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
|
||||||
});
|
|
||||||
|
|
||||||
function connect(connectable) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
connectable.on('connect', () => {
|
|
||||||
return resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('\'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
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
await 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
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
await 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
#
|
|
||||||
# Recommended minimum configuration:
|
|
||||||
#
|
|
||||||
|
|
||||||
# Example rule allowing access from your local networks.
|
|
||||||
# Adapt to list your (internal) IP networks from where browsing
|
|
||||||
# should be allowed
|
|
||||||
acl localnet src 0.0.0.1-0.255.255.255 # RFC 1122 "this" network (LAN)
|
|
||||||
acl localnet src 10.0.0.0/8 # RFC 1918 local private network (LAN)
|
|
||||||
acl localnet src 100.64.0.0/10 # RFC 6598 shared address space (CGN)
|
|
||||||
acl localnet src 169.254.0.0/16 # RFC 3927 link-local (directly plugged) machines
|
|
||||||
acl localnet src 172.16.0.0/12 # RFC 1918 local private network (LAN)
|
|
||||||
acl localnet src 172.38.0.0/12 # RFC 1918 local private network (LAN)
|
|
||||||
acl localnet src 192.168.0.0/16 # RFC 1918 local private network (LAN)
|
|
||||||
acl localnet src fc00::/7 # RFC 4193 local private network range
|
|
||||||
acl localnet src fe80::/10 # RFC 4291 link-local (directly plugged) machines
|
|
||||||
|
|
||||||
acl SSL_ports port 443
|
|
||||||
acl Safe_ports port 80 # http
|
|
||||||
acl Safe_ports port 21 # ftp
|
|
||||||
acl Safe_ports port 443 # https
|
|
||||||
acl Safe_ports port 70 # gopher
|
|
||||||
acl Safe_ports port 210 # wais
|
|
||||||
acl Safe_ports port 1025-65535 # unregistered ports
|
|
||||||
acl Safe_ports port 280 # http-mgmt
|
|
||||||
acl Safe_ports port 488 # gss-http
|
|
||||||
acl Safe_ports port 591 # filemaker
|
|
||||||
acl Safe_ports port 777 # multiling http
|
|
||||||
|
|
||||||
#
|
|
||||||
# Recommended minimum Access Permission configuration:
|
|
||||||
#
|
|
||||||
# Deny requests to certain unsafe ports
|
|
||||||
http_access allow !Safe_ports
|
|
||||||
|
|
||||||
# Deny CONNECT to other than secure SSL ports
|
|
||||||
http_access allow CONNECT !SSL_ports
|
|
||||||
|
|
||||||
# Only allow cachemgr access from localhost
|
|
||||||
http_access allow localhost manager
|
|
||||||
http_access allow manager
|
|
||||||
|
|
||||||
# This default configuration only allows localhost requests because a more
|
|
||||||
# permissive Squid installation could introduce new attack vectors into the
|
|
||||||
# network by proxying external TCP connections to unprotected services.
|
|
||||||
http_access allow localhost
|
|
||||||
|
|
||||||
# The two deny rules below are unnecessary in this default configuration
|
|
||||||
# because they are followed by a "deny all" rule. However, they may become
|
|
||||||
# critically important when you start allowing external requests below them.
|
|
||||||
|
|
||||||
# Protect web applications running on the same server as Squid. They often
|
|
||||||
# assume that only local users can access them at "localhost" ports.
|
|
||||||
http_access allow to_localhost
|
|
||||||
|
|
||||||
# Protect cloud servers that provide local users with sensitive info about
|
|
||||||
# their server via certain well-known link-local (a.k.a. APIPA) addresses.
|
|
||||||
# http_access deny to_linklocal
|
|
||||||
|
|
||||||
#
|
|
||||||
# INSERT YOUR OWN RULE(S) HERE TO ALLOW ACCESS FROM YOUR CLIENTS
|
|
||||||
#
|
|
||||||
|
|
||||||
# For example, to allow access from your local networks, you may uncomment the
|
|
||||||
# following rule (and/or add rules that match your definition of "local"):
|
|
||||||
# http_access allow localnet
|
|
||||||
|
|
||||||
# And finally deny all other access to this proxy
|
|
||||||
http_access allow all
|
|
||||||
|
|
||||||
# Squid normally listens to port 3128
|
|
||||||
http_port 3128
|
|
||||||
|
|
||||||
# Uncomment and adjust the following to add a disk cache directory.
|
|
||||||
#cache_dir ufs /usr/local/var/cache/squid 100 16 256
|
|
||||||
|
|
||||||
# Leave coredumps in the first cache dir
|
|
||||||
coredump_dir /usr/local/var/cache/squid
|
|
||||||
|
|
||||||
#
|
|
||||||
# Add any of your own refresh_pattern entries above these.
|
|
||||||
#
|
|
||||||
refresh_pattern ^ftp: 1440 20% 10080
|
|
||||||
refresh_pattern ^gopher: 1440 0% 1440
|
|
||||||
refresh_pattern -i (/cgi-bin/|\?) 0 0% 0
|
|
||||||
refresh_pattern . 0 20% 4320
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
const test = require('tape');
|
|
||||||
const { sippUac } = require('./sipp')('test_fs');
|
|
||||||
const bent = require('bent');
|
|
||||||
const clearModule = require('clear-module');
|
|
||||||
const {provisionCallHook} = require('./utils')
|
|
||||||
const getJSON = bent('json')
|
|
||||||
|
|
||||||
const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason, p) => {
|
|
||||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
|
||||||
});
|
|
||||||
|
|
||||||
function connect(connectable) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
connectable.on('connect', () => {
|
|
||||||
return resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('test create-call timeout', async(t) => {
|
|
||||||
clearModule.all();
|
|
||||||
const {srf, disconnect} = require('../app');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connect(srf);
|
|
||||||
|
|
||||||
// give UAS app time to come up
|
|
||||||
const p = sippUac('uas-timeout-cancel.xml', '172.38.0.10');
|
|
||||||
await waitFor(1000);
|
|
||||||
|
|
||||||
// GIVEN
|
|
||||||
let account_sid = '622f62e4-303a-49f2-bbe0-eb1e1714e37a';
|
|
||||||
const post = bent('http://127.0.0.1:3000/', 'POST', 'json', 201);
|
|
||||||
post('v1/createCall', {
|
|
||||||
'account_sid':account_sid,
|
|
||||||
'timeout': 1,
|
|
||||||
"call_hook": {
|
|
||||||
"url": "https://public-apps.jambonz.cloud/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": "pause",
|
|
||||||
"length": 1
|
|
||||||
}
|
|
||||||
];
|
|
||||||
await 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
|
|
||||||
}
|
|
||||||
];
|
|
||||||
await 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test create-call app_json', async(t) => {
|
|
||||||
clearModule.all();
|
|
||||||
const {srf, disconnect} = require('../app');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connect(srf);
|
|
||||||
|
|
||||||
|
|
||||||
// GIVEN
|
|
||||||
let from = 'create-call-app-json';
|
|
||||||
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 app_json = `[
|
|
||||||
{
|
|
||||||
"verb": "pause",
|
|
||||||
"length": 7
|
|
||||||
}
|
|
||||||
]`;
|
|
||||||
|
|
||||||
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"
|
|
||||||
},
|
|
||||||
app_json,
|
|
||||||
"from": from,
|
|
||||||
"to": {
|
|
||||||
"type": "phone",
|
|
||||||
"number": "15583084809"
|
|
||||||
},
|
|
||||||
"amd": {
|
|
||||||
"actionHook": "/actionHook"
|
|
||||||
},
|
|
||||||
"speech_recognizer_vendor": "google",
|
|
||||||
"speech_recognizer_language": "en"
|
|
||||||
});
|
|
||||||
|
|
||||||
//THEN
|
|
||||||
await p;
|
|
||||||
|
|
||||||
disconnect();
|
|
||||||
} catch (err) {
|
|
||||||
console.log(`error received: ${err}`);
|
|
||||||
disconnect();
|
|
||||||
t.error(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,20 +1,9 @@
|
|||||||
const test = require('tape') ;
|
const test = require('tape').test ;
|
||||||
const exec = require('child_process').exec ;
|
const exec = require('child_process').exec ;
|
||||||
const fs = require('fs');
|
const pwd = process.env.TRAVIS ? '' : '-p$MYSQL_ROOT_PASSWORD';
|
||||||
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) => {
|
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) => {
|
exec(`mysql -h localhost -u root ${pwd} < ${__dirname}/db/create_test_db.sql`, (err, stdout, stderr) => {
|
||||||
console.log(stdout);
|
|
||||||
console.log(stderr)
|
|
||||||
if (err) return t.end(err);
|
if (err) return t.end(err);
|
||||||
t.pass('database successfully created');
|
t.pass('database successfully created');
|
||||||
t.end();
|
t.end();
|
||||||
@@ -22,49 +11,17 @@ test('creating jambones_test database', (t) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('creating schema', (t) => {
|
test('creating schema', (t) => {
|
||||||
exec(`mysql -h 127.0.0.1 -u root --protocol=tcp --port=3360 -D jambones_test < ${__dirname}/db/create-and-populate-schema.sql`, (err, stdout, stderr) => {
|
exec(`mysql -h localhost -u root ${pwd} -D jambones_test < ${__dirname}/db/jambones-sql.sql`, (err, stdout, stderr) => {
|
||||||
if (err) return t.end(err);
|
if (err) return t.end(err);
|
||||||
t.pass('schema and test data successfully created');
|
t.pass('schema successfully created');
|
||||||
|
t.end();
|
||||||
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: AWS_ACCESS_KEY_ID,
|
|
||||||
secret_access_key: AWS_SECRET_ACCESS_KEY,
|
|
||||||
aws_region: AWS_REGION
|
|
||||||
}));
|
|
||||||
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`;
|
|
||||||
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)
|
|
||||||
t.pass('set account-level speech credentials');
|
|
||||||
t.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
t.end();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('populating test case data', (t) => {
|
||||||
|
exec(`mysql -h localhost -u root ${pwd} -D jambones_test < ${__dirname}/db/populate-test-data.sql`, (err, stdout, stderr) => {
|
||||||
|
if (err) return t.end(err);
|
||||||
|
t.pass('test data set created');
|
||||||
|
t.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,3 @@
|
|||||||
create database jambones_test;
|
create database jambones_test;
|
||||||
create user jambones_test@'%' IDENTIFIED WITH mysql_native_password by 'jambones_test';
|
create user jambones_test@localhost IDENTIFIED WITH mysql_native_password by 'jambones_test';
|
||||||
grant all on jambones_test.* to jambones_test@'%';
|
grant all on jambones_test.* to jambones_test@localhost;
|
||||||
|
|||||||
@@ -1,66 +1,21 @@
|
|||||||
/* SQLEditor (MySQL (2))*/
|
/* SQLEditor (MySQL (2))*/
|
||||||
|
|
||||||
SET FOREIGN_KEY_CHECKS=0;
|
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;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS beta_invite_codes;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS call_routes;
|
DROP TABLE IF EXISTS call_routes;
|
||||||
|
|
||||||
DROP TABLE IF EXISTS clients;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS dns_records;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS lcr;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS lcr_carrier_set_entry;
|
DROP TABLE IF EXISTS lcr_carrier_set_entry;
|
||||||
|
|
||||||
DROP TABLE IF EXISTS lcr_routes;
|
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;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS predefined_carriers;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS account_offers;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS products;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS schema_version;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS api_keys;
|
DROP TABLE IF EXISTS api_keys;
|
||||||
|
|
||||||
DROP TABLE IF EXISTS sbc_addresses;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS ms_teams_tenants;
|
DROP TABLE IF EXISTS ms_teams_tenants;
|
||||||
|
|
||||||
DROP TABLE IF EXISTS service_provider_limits;
|
DROP TABLE IF EXISTS sbc_addresses;
|
||||||
|
|
||||||
DROP TABLE IF EXISTS signup_history;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS smpp_addresses;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS speech_credentials;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS system_information;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS users;
|
DROP TABLE IF EXISTS users;
|
||||||
|
|
||||||
DROP TABLE IF EXISTS smpp_gateways;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS phone_numbers;
|
DROP TABLE IF EXISTS phone_numbers;
|
||||||
|
|
||||||
DROP TABLE IF EXISTS sip_gateways;
|
DROP TABLE IF EXISTS sip_gateways;
|
||||||
@@ -75,50 +30,6 @@ DROP TABLE IF EXISTS service_providers;
|
|||||||
|
|
||||||
DROP TABLE IF EXISTS webhooks;
|
DROP TABLE IF EXISTS webhooks;
|
||||||
|
|
||||||
CREATE TABLE account_static_ips
|
|
||||||
(
|
|
||||||
account_static_ip_sid CHAR(36) NOT NULL UNIQUE ,
|
|
||||||
account_sid CHAR(36) NOT NULL,
|
|
||||||
public_ipv4 VARCHAR(16) NOT NULL UNIQUE ,
|
|
||||||
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 ,
|
|
||||||
account_sid CHAR(36) NOT NULL,
|
|
||||||
pending BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
effective_start_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
effective_end_date DATETIME,
|
|
||||||
change_reason VARCHAR(255),
|
|
||||||
stripe_subscription_id VARCHAR(56),
|
|
||||||
stripe_payment_method_id VARCHAR(56),
|
|
||||||
stripe_statement_descriptor VARCHAR(255),
|
|
||||||
last4 VARCHAR(512),
|
|
||||||
exp_month INTEGER,
|
|
||||||
exp_year INTEGER,
|
|
||||||
card_type VARCHAR(16),
|
|
||||||
pending_reason VARBINARY(52),
|
|
||||||
PRIMARY KEY (account_subscription_sid)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE beta_invite_codes
|
|
||||||
(
|
|
||||||
invite_code CHAR(6) NOT NULL UNIQUE ,
|
|
||||||
in_use BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
PRIMARY KEY (invite_code)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE call_routes
|
CREATE TABLE call_routes
|
||||||
(
|
(
|
||||||
call_route_sid CHAR(36) NOT NULL UNIQUE ,
|
call_route_sid CHAR(36) NOT NULL UNIQUE ,
|
||||||
@@ -127,136 +38,16 @@ account_sid CHAR(36) NOT NULL,
|
|||||||
regex VARCHAR(255) NOT NULL,
|
regex VARCHAR(255) NOT NULL,
|
||||||
application_sid CHAR(36) NOT NULL,
|
application_sid CHAR(36) NOT NULL,
|
||||||
PRIMARY KEY (call_route_sid)
|
PRIMARY KEY (call_route_sid)
|
||||||
) COMMENT='a regex-based pattern match for call routing';
|
) ENGINE=InnoDB COMMENT='a regex-based pattern match for call routing';
|
||||||
|
|
||||||
CREATE TABLE clients
|
|
||||||
(
|
|
||||||
client_sid CHAR(36) NOT NULL UNIQUE ,
|
|
||||||
account_sid CHAR(36) NOT NULL,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
|
||||||
username VARCHAR(64),
|
|
||||||
password VARCHAR(1024),
|
|
||||||
PRIMARY KEY (client_sid)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE dns_records
|
|
||||||
(
|
|
||||||
dns_record_sid CHAR(36) NOT NULL UNIQUE ,
|
|
||||||
account_sid CHAR(36) NOT NULL,
|
|
||||||
record_type VARCHAR(6) NOT NULL,
|
|
||||||
record_id INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (dns_record_sid)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE lcr_routes
|
CREATE TABLE lcr_routes
|
||||||
(
|
(
|
||||||
lcr_route_sid CHAR(36),
|
lcr_route_sid CHAR(36),
|
||||||
lcr_sid CHAR(36) NOT NULL,
|
|
||||||
regex VARCHAR(32) NOT NULL COMMENT 'regex-based pattern match against dialed number, used for LCR routing of PSTN calls',
|
regex VARCHAR(32) NOT NULL COMMENT 'regex-based pattern match against dialed number, used for LCR routing of PSTN calls',
|
||||||
description VARCHAR(1024),
|
description VARCHAR(1024),
|
||||||
priority INTEGER NOT NULL COMMENT 'lower priority routes are attempted first',
|
priority INTEGER NOT NULL UNIQUE COMMENT 'lower priority routes are attempted first',
|
||||||
PRIMARY KEY (lcr_route_sid)
|
PRIMARY KEY (lcr_route_sid)
|
||||||
) COMMENT='An ordered list of digit patterns in an LCR table. The patterns are tested in sequence until one matches';
|
) COMMENT='Least cost routing table';
|
||||||
|
|
||||||
CREATE TABLE lcr
|
|
||||||
(
|
|
||||||
lcr_sid CHAR(36) NOT NULL UNIQUE ,
|
|
||||||
name VARCHAR(64) COMMENT 'User-assigned name for this LCR table',
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
|
||||||
default_carrier_set_entry_sid CHAR(36) COMMENT 'default carrier/route to use when no digit match based results are found.',
|
|
||||||
service_provider_sid CHAR(36),
|
|
||||||
account_sid CHAR(36),
|
|
||||||
PRIMARY KEY (lcr_sid)
|
|
||||||
) COMMENT='An LCR (least cost routing) table that is used by a service provider or account to make decisions about routing outbound calls when multiple carriers are available.';
|
|
||||||
|
|
||||||
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 ,
|
|
||||||
name VARCHAR(64) NOT NULL,
|
|
||||||
requires_static_ip BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
e164_leading_plus BOOLEAN NOT NULL DEFAULT false COMMENT 'if true, a leading plus should be prepended to outbound phone numbers',
|
|
||||||
requires_register BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
register_username VARCHAR(64),
|
|
||||||
register_sip_realm VARCHAR(64),
|
|
||||||
register_password VARCHAR(64),
|
|
||||||
tech_prefix VARCHAR(16) COMMENT 'tech prefix to prepend to outbound calls to this carrier',
|
|
||||||
inbound_auth_username VARCHAR(64),
|
|
||||||
inbound_auth_password VARCHAR(64),
|
|
||||||
diversion VARCHAR(32),
|
|
||||||
PRIMARY KEY (predefined_carrier_sid)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE predefined_sip_gateways
|
|
||||||
(
|
|
||||||
predefined_sip_gateway_sid CHAR(36) NOT NULL UNIQUE ,
|
|
||||||
ipv4 VARCHAR(128) NOT NULL COMMENT 'ip address or DNS name of the gateway. For gateways providing inbound calling service, ip address is required.',
|
|
||||||
port INTEGER NOT NULL DEFAULT 5060 COMMENT 'sip signaling port',
|
|
||||||
inbound BOOLEAN NOT NULL COMMENT 'if true, whitelist this IP to allow inbound calls from the gateway',
|
|
||||||
outbound BOOLEAN NOT NULL COMMENT 'if true, include in least-cost routing when placing calls to the PSTN',
|
|
||||||
netmask INTEGER NOT NULL DEFAULT 32,
|
|
||||||
predefined_carrier_sid CHAR(36) NOT NULL,
|
|
||||||
PRIMARY KEY (predefined_sip_gateway_sid)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE predefined_smpp_gateways
|
|
||||||
(
|
|
||||||
predefined_smpp_gateway_sid CHAR(36) NOT NULL UNIQUE ,
|
|
||||||
ipv4 VARCHAR(128) NOT NULL COMMENT 'ip address or DNS name of the gateway. ',
|
|
||||||
port INTEGER NOT NULL DEFAULT 2775 COMMENT 'smpp signaling port',
|
|
||||||
inbound BOOLEAN NOT NULL COMMENT 'if true, whitelist this IP to allow inbound SMS from the gateway',
|
|
||||||
outbound BOOLEAN NOT NULL COMMENT 'i',
|
|
||||||
netmask INTEGER NOT NULL DEFAULT 32,
|
|
||||||
is_primary BOOLEAN NOT NULL DEFAULT 1,
|
|
||||||
use_tls BOOLEAN DEFAULT 0,
|
|
||||||
predefined_carrier_sid CHAR(36) NOT NULL,
|
|
||||||
PRIMARY KEY (predefined_smpp_gateway_sid)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE products
|
|
||||||
(
|
|
||||||
product_sid CHAR(36) NOT NULL UNIQUE ,
|
|
||||||
name VARCHAR(32) NOT NULL,
|
|
||||||
category ENUM('api_rate','voice_call_session', 'device') NOT NULL,
|
|
||||||
PRIMARY KEY (product_sid)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE account_products
|
|
||||||
(
|
|
||||||
account_product_sid CHAR(36) NOT NULL UNIQUE ,
|
|
||||||
account_subscription_sid CHAR(36) NOT NULL,
|
|
||||||
product_sid CHAR(36) NOT NULL,
|
|
||||||
quantity INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (account_product_sid)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE account_offers
|
|
||||||
(
|
|
||||||
account_offer_sid CHAR(36) NOT NULL UNIQUE ,
|
|
||||||
account_sid CHAR(36) NOT NULL,
|
|
||||||
product_sid CHAR(36) NOT NULL,
|
|
||||||
stripe_product_id VARCHAR(56) NOT NULL,
|
|
||||||
PRIMARY KEY (account_offer_sid)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE schema_version
|
|
||||||
(
|
|
||||||
version VARCHAR(16)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE api_keys
|
CREATE TABLE api_keys
|
||||||
(
|
(
|
||||||
@@ -264,23 +55,9 @@ api_key_sid CHAR(36) NOT NULL UNIQUE ,
|
|||||||
token CHAR(36) NOT NULL UNIQUE ,
|
token CHAR(36) NOT NULL UNIQUE ,
|
||||||
account_sid CHAR(36),
|
account_sid CHAR(36),
|
||||||
service_provider_sid CHAR(36),
|
service_provider_sid CHAR(36),
|
||||||
expires_at TIMESTAMP NULL DEFAULT NULL,
|
expires_at TIMESTAMP,
|
||||||
last_used TIMESTAMP NULL DEFAULT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (api_key_sid)
|
PRIMARY KEY (api_key_sid)
|
||||||
) COMMENT='An authorization token that is used to access the REST api';
|
) ENGINE=InnoDB COMMENT='An authorization token that is used to access the REST api';
|
||||||
|
|
||||||
CREATE TABLE sbc_addresses
|
|
||||||
(
|
|
||||||
sbc_address_sid CHAR(36) NOT NULL UNIQUE ,
|
|
||||||
ipv4 VARCHAR(255) NOT NULL,
|
|
||||||
port INTEGER NOT NULL DEFAULT 5060,
|
|
||||||
tls_port INTEGER,
|
|
||||||
wss_port INTEGER,
|
|
||||||
service_provider_sid CHAR(36),
|
|
||||||
last_updated DATETIME,
|
|
||||||
PRIMARY KEY (sbc_address_sid)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE ms_teams_tenants
|
CREATE TABLE ms_teams_tenants
|
||||||
(
|
(
|
||||||
@@ -292,157 +69,65 @@ tenant_fqdn VARCHAR(255) NOT NULL UNIQUE ,
|
|||||||
PRIMARY KEY (ms_teams_tenant_sid)
|
PRIMARY KEY (ms_teams_tenant_sid)
|
||||||
) COMMENT='A Microsoft Teams customer tenant';
|
) COMMENT='A Microsoft Teams customer tenant';
|
||||||
|
|
||||||
CREATE TABLE service_provider_limits
|
CREATE TABLE sbc_addresses
|
||||||
(
|
(
|
||||||
service_provider_limits_sid CHAR(36) NOT NULL UNIQUE ,
|
sbc_address_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,
|
|
||||||
name VARCHAR(255),
|
|
||||||
signed_up_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (email)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE smpp_addresses
|
|
||||||
(
|
|
||||||
smpp_address_sid CHAR(36) NOT NULL UNIQUE ,
|
|
||||||
ipv4 VARCHAR(255) NOT NULL,
|
ipv4 VARCHAR(255) NOT NULL,
|
||||||
port INTEGER NOT NULL DEFAULT 5060,
|
port INTEGER NOT NULL DEFAULT 5060,
|
||||||
use_tls BOOLEAN NOT NULL DEFAULT 0,
|
|
||||||
is_primary BOOLEAN NOT NULL DEFAULT 1,
|
|
||||||
service_provider_sid CHAR(36),
|
service_provider_sid CHAR(36),
|
||||||
PRIMARY KEY (smpp_address_sid)
|
PRIMARY KEY (sbc_address_sid)
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE speech_credentials
|
|
||||||
(
|
|
||||||
speech_credential_sid CHAR(36) NOT NULL UNIQUE ,
|
|
||||||
service_provider_sid CHAR(36),
|
|
||||||
account_sid CHAR(36),
|
|
||||||
vendor VARCHAR(32) NOT NULL,
|
|
||||||
credential VARCHAR(8192) NOT NULL,
|
|
||||||
use_for_tts BOOLEAN DEFAULT true,
|
|
||||||
use_for_stt BOOLEAN DEFAULT true,
|
|
||||||
last_used DATETIME,
|
|
||||||
last_tested DATETIME,
|
|
||||||
tts_tested_ok BOOLEAN,
|
|
||||||
stt_tested_ok BOOLEAN,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
label VARCHAR(64),
|
|
||||||
PRIMARY KEY (speech_credential_sid)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE system_information
|
|
||||||
(
|
|
||||||
domain_name VARCHAR(255),
|
|
||||||
sip_domain_name VARCHAR(255),
|
|
||||||
monitoring_domain_name VARCHAR(255)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE users
|
CREATE TABLE users
|
||||||
(
|
(
|
||||||
user_sid CHAR(36) NOT NULL UNIQUE ,
|
user_sid CHAR(36) NOT NULL UNIQUE ,
|
||||||
name VARCHAR(255) NOT NULL,
|
name CHAR(36) NOT NULL UNIQUE ,
|
||||||
email VARCHAR(255) NOT NULL,
|
hashed_password VARCHAR(1024) NOT NULL,
|
||||||
pending_email VARCHAR(255),
|
salt CHAR(16) NOT NULL,
|
||||||
phone VARCHAR(20) UNIQUE ,
|
force_change BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
hashed_password VARCHAR(1024),
|
|
||||||
account_sid CHAR(36),
|
|
||||||
service_provider_sid CHAR(36),
|
|
||||||
force_change BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
provider VARCHAR(255) NOT NULL,
|
|
||||||
provider_userid VARCHAR(255),
|
|
||||||
scope VARCHAR(16) NOT NULL DEFAULT 'read-write',
|
|
||||||
phone_activation_code VARCHAR(16),
|
|
||||||
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)
|
PRIMARY KEY (user_sid)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE voip_carriers
|
CREATE TABLE voip_carriers
|
||||||
(
|
(
|
||||||
voip_carrier_sid CHAR(36) NOT NULL UNIQUE ,
|
voip_carrier_sid CHAR(36) NOT NULL UNIQUE ,
|
||||||
name VARCHAR(64) NOT NULL,
|
name VARCHAR(64) NOT NULL UNIQUE ,
|
||||||
description VARCHAR(255),
|
description VARCHAR(255),
|
||||||
account_sid CHAR(36) COMMENT 'if provided, indicates this entity represents a sip trunk that is associated with a specific account',
|
account_sid CHAR(36) COMMENT 'if provided, indicates this entity represents a customer PBX that is associated with a specific account',
|
||||||
service_provider_sid CHAR(36),
|
|
||||||
application_sid CHAR(36) COMMENT 'If provided, all incoming calls from this source will be routed to the associated application',
|
application_sid CHAR(36) COMMENT 'If provided, all incoming calls from this source will be routed to the associated application',
|
||||||
e164_leading_plus BOOLEAN NOT NULL DEFAULT false COMMENT 'if true, a leading plus should be prepended to outbound phone numbers',
|
e164_leading_plus BOOLEAN NOT NULL DEFAULT false,
|
||||||
requires_register BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
register_username VARCHAR(64),
|
|
||||||
register_sip_realm VARCHAR(64),
|
|
||||||
register_password VARCHAR(64),
|
|
||||||
tech_prefix VARCHAR(16) COMMENT 'tech prefix to prepend to outbound calls to this carrier',
|
|
||||||
inbound_auth_username VARCHAR(64),
|
|
||||||
inbound_auth_password VARCHAR(64),
|
|
||||||
diversion VARCHAR(32),
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
smpp_system_id VARCHAR(255),
|
|
||||||
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,
|
|
||||||
register_status VARCHAR(4096),
|
|
||||||
PRIMARY KEY (voip_carrier_sid)
|
PRIMARY KEY (voip_carrier_sid)
|
||||||
) COMMENT='A Carrier or customer PBX that can send or receive calls';
|
) ENGINE=InnoDB 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 ,
|
|
||||||
ipv4 VARCHAR(128) NOT NULL,
|
|
||||||
port INTEGER NOT NULL DEFAULT 2775,
|
|
||||||
netmask INTEGER NOT NULL DEFAULT 32,
|
|
||||||
is_primary BOOLEAN NOT NULL DEFAULT 1,
|
|
||||||
inbound BOOLEAN NOT NULL DEFAULT 0 COMMENT 'if true, whitelist this IP to allow inbound calls from the gateway',
|
|
||||||
outbound BOOLEAN NOT NULL DEFAULT 1 COMMENT 'if true, include in least-cost routing when placing calls to the PSTN',
|
|
||||||
use_tls BOOLEAN DEFAULT 0,
|
|
||||||
voip_carrier_sid CHAR(36) NOT NULL,
|
|
||||||
PRIMARY KEY (smpp_gateway_sid)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE phone_numbers
|
CREATE TABLE phone_numbers
|
||||||
(
|
(
|
||||||
phone_number_sid CHAR(36) UNIQUE ,
|
phone_number_sid CHAR(36) UNIQUE ,
|
||||||
number VARCHAR(132) NOT NULL,
|
number VARCHAR(32) NOT NULL UNIQUE ,
|
||||||
voip_carrier_sid CHAR(36),
|
voip_carrier_sid CHAR(36) NOT NULL,
|
||||||
account_sid CHAR(36),
|
account_sid CHAR(36),
|
||||||
application_sid CHAR(36),
|
application_sid CHAR(36),
|
||||||
service_provider_sid CHAR(36) COMMENT 'if not null, this number is a test number for the associated service provider',
|
|
||||||
PRIMARY KEY (phone_number_sid)
|
PRIMARY KEY (phone_number_sid)
|
||||||
) COMMENT='A phone number that has been assigned to an account';
|
) ENGINE=InnoDB COMMENT='A phone number that has been assigned to an account';
|
||||||
|
|
||||||
|
CREATE TABLE webhooks
|
||||||
|
(
|
||||||
|
webhook_sid CHAR(36) NOT NULL UNIQUE ,
|
||||||
|
url VARCHAR(1024) NOT NULL,
|
||||||
|
method ENUM("GET","POST") NOT NULL DEFAULT 'POST',
|
||||||
|
username VARCHAR(255),
|
||||||
|
password VARCHAR(255),
|
||||||
|
PRIMARY KEY (webhook_sid)
|
||||||
|
) COMMENT='An HTTP callback';
|
||||||
|
|
||||||
CREATE TABLE sip_gateways
|
CREATE TABLE sip_gateways
|
||||||
(
|
(
|
||||||
sip_gateway_sid CHAR(36),
|
sip_gateway_sid CHAR(36),
|
||||||
ipv4 VARCHAR(128) NOT NULL COMMENT 'ip address or DNS name of the gateway. For gateways providing inbound calling service, ip address is required.',
|
ipv4 VARCHAR(128) NOT NULL COMMENT 'ip address or DNS name of the gateway. For gateways providing inbound calling service, ip address is required.',
|
||||||
netmask INTEGER NOT NULL DEFAULT 32,
|
|
||||||
port INTEGER NOT NULL DEFAULT 5060 COMMENT 'sip signaling port',
|
port INTEGER NOT NULL DEFAULT 5060 COMMENT 'sip signaling port',
|
||||||
inbound BOOLEAN NOT NULL COMMENT 'if true, whitelist this IP to allow inbound calls from the gateway',
|
inbound BOOLEAN NOT NULL COMMENT 'if true, whitelist this IP to allow inbound calls from the gateway',
|
||||||
outbound BOOLEAN NOT NULL COMMENT 'if true, include in least-cost routing when placing calls to the PSTN',
|
outbound BOOLEAN NOT NULL COMMENT 'if true, include in least-cost routing when placing calls to the PSTN',
|
||||||
voip_carrier_sid CHAR(36) NOT NULL,
|
voip_carrier_sid CHAR(36) NOT NULL,
|
||||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||||
protocol ENUM('udp','tcp','tls', 'tls/srtp') DEFAULT 'udp' COMMENT 'Outbound call protocol',
|
|
||||||
PRIMARY KEY (sip_gateway_sid)
|
PRIMARY KEY (sip_gateway_sid)
|
||||||
) COMMENT='A whitelisted sip gateway used for origination/termination';
|
) COMMENT='A whitelisted sip gateway used for origination/termination';
|
||||||
|
|
||||||
@@ -456,37 +141,20 @@ priority INTEGER NOT NULL DEFAULT 0 COMMENT 'lower priority carriers are attempt
|
|||||||
PRIMARY KEY (lcr_carrier_set_entry_sid)
|
PRIMARY KEY (lcr_carrier_set_entry_sid)
|
||||||
) COMMENT='An entry in the LCR routing list';
|
) COMMENT='An entry in the LCR routing list';
|
||||||
|
|
||||||
CREATE TABLE webhooks
|
|
||||||
(
|
|
||||||
webhook_sid CHAR(36) NOT NULL UNIQUE ,
|
|
||||||
url VARCHAR(1024) NOT NULL,
|
|
||||||
method ENUM("GET","POST") NOT NULL DEFAULT 'POST',
|
|
||||||
username VARCHAR(255),
|
|
||||||
password VARCHAR(255),
|
|
||||||
PRIMARY KEY (webhook_sid)
|
|
||||||
) COMMENT='An HTTP callback';
|
|
||||||
|
|
||||||
CREATE TABLE applications
|
CREATE TABLE applications
|
||||||
(
|
(
|
||||||
application_sid CHAR(36) NOT NULL UNIQUE ,
|
application_sid CHAR(36) NOT NULL UNIQUE ,
|
||||||
name VARCHAR(64) NOT NULL,
|
name VARCHAR(64) NOT NULL,
|
||||||
service_provider_sid CHAR(36) COMMENT 'if non-null, this application is a test application that can be used by any account under the associated service provider',
|
account_sid CHAR(36) NOT NULL COMMENT 'account that this application belongs to',
|
||||||
account_sid CHAR(36) COMMENT 'account that this application belongs to (if null, this is a service provider test application)',
|
call_hook_sid CHAR(36) COMMENT 'webhook to call for inbound calls to phone numbers owned by this account',
|
||||||
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',
|
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 TEXT,
|
|
||||||
speech_synthesis_vendor VARCHAR(64) NOT NULL DEFAULT 'google',
|
speech_synthesis_vendor VARCHAR(64) NOT NULL DEFAULT 'google',
|
||||||
speech_synthesis_language VARCHAR(12) NOT NULL DEFAULT 'en-US',
|
speech_synthesis_language VARCHAR(12) NOT NULL DEFAULT 'en-US',
|
||||||
speech_synthesis_voice VARCHAR(64),
|
speech_synthesis_voice VARCHAR(64),
|
||||||
speech_synthesis_label VARCHAR(64),
|
|
||||||
speech_recognizer_vendor VARCHAR(64) NOT NULL DEFAULT 'google',
|
speech_recognizer_vendor VARCHAR(64) NOT NULL DEFAULT 'google',
|
||||||
speech_recognizer_language VARCHAR(64) NOT NULL DEFAULT 'en-US',
|
speech_recognizer_language VARCHAR(64) NOT NULL DEFAULT 'en-US',
|
||||||
speech_recognizer_label VARCHAR(64),
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
record_all_calls BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
PRIMARY KEY (application_sid)
|
PRIMARY KEY (application_sid)
|
||||||
) COMMENT='A defined set of behaviors to be applied to phone calls ';
|
) ENGINE=InnoDB COMMENT='A defined set of behaviors to be applied to phone calls ';
|
||||||
|
|
||||||
CREATE TABLE service_providers
|
CREATE TABLE service_providers
|
||||||
(
|
(
|
||||||
@@ -497,7 +165,7 @@ root_domain VARCHAR(128) UNIQUE ,
|
|||||||
registration_hook_sid CHAR(36),
|
registration_hook_sid CHAR(36),
|
||||||
ms_teams_fqdn VARCHAR(255),
|
ms_teams_fqdn VARCHAR(255),
|
||||||
PRIMARY KEY (service_provider_sid)
|
PRIMARY KEY (service_provider_sid)
|
||||||
) COMMENT='A partition of the platform used by one service provider';
|
) ENGINE=InnoDB COMMENT='A partition of the platform used by one service provider';
|
||||||
|
|
||||||
CREATE TABLE accounts
|
CREATE TABLE accounts
|
||||||
(
|
(
|
||||||
@@ -506,187 +174,72 @@ name VARCHAR(64) NOT NULL,
|
|||||||
sip_realm VARCHAR(132) UNIQUE COMMENT 'sip domain that will be used for devices registering under this account',
|
sip_realm VARCHAR(132) UNIQUE COMMENT 'sip domain that will be used for devices registering under this account',
|
||||||
service_provider_sid CHAR(36) NOT NULL COMMENT 'service provider that owns the customer relationship with this account',
|
service_provider_sid CHAR(36) NOT NULL COMMENT 'service provider that owns the customer relationship with this account',
|
||||||
registration_hook_sid CHAR(36) COMMENT 'webhook to call when devices underr this account attempt to register',
|
registration_hook_sid CHAR(36) COMMENT 'webhook to call when devices underr this account attempt to register',
|
||||||
queue_event_hook_sid CHAR(36),
|
|
||||||
device_calling_application_sid CHAR(36) COMMENT 'application to use for outbound calling from an account',
|
device_calling_application_sid CHAR(36) COMMENT 'application to use for outbound calling from an account',
|
||||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
plan_type ENUM('trial','free','paid') NOT NULL DEFAULT 'trial',
|
|
||||||
stripe_customer_id VARCHAR(56),
|
|
||||||
webhook_secret VARCHAR(36) NOT NULL,
|
|
||||||
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),
|
|
||||||
record_all_calls BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
record_format VARCHAR(16) NOT NULL DEFAULT 'mp3',
|
|
||||||
bucket_credential VARCHAR(8192) COMMENT 'credential used to authenticate with storage service',
|
|
||||||
PRIMARY KEY (account_sid)
|
PRIMARY KEY (account_sid)
|
||||||
) COMMENT='An enterprise that uses the platform for comm services';
|
) ENGINE=InnoDB COMMENT='An enterprise that uses the platform for comm services';
|
||||||
|
|
||||||
CREATE INDEX account_static_ip_sid_idx ON account_static_ips (account_static_ip_sid);
|
|
||||||
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_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);
|
CREATE INDEX call_route_sid_idx ON call_routes (call_route_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 account_sid_idxfk (account_sid) REFERENCES accounts (account_sid);
|
||||||
|
|
||||||
ALTER TABLE call_routes ADD FOREIGN KEY application_sid_idxfk (application_sid) REFERENCES applications (application_sid);
|
ALTER TABLE call_routes ADD FOREIGN KEY application_sid_idxfk (application_sid) REFERENCES applications (application_sid);
|
||||||
|
|
||||||
CREATE INDEX client_sid_idx ON clients (client_sid);
|
|
||||||
ALTER TABLE clients ADD CONSTRAINT account_sid_idxfk_13 FOREIGN KEY account_sid_idxfk_13 (account_sid) REFERENCES accounts (account_sid);
|
|
||||||
|
|
||||||
CREATE INDEX dns_record_sid_idx ON dns_records (dns_record_sid);
|
|
||||||
ALTER TABLE dns_records ADD FOREIGN KEY account_sid_idxfk_4 (account_sid) REFERENCES accounts (account_sid);
|
|
||||||
|
|
||||||
CREATE INDEX lcr_sid_idx ON lcr_routes (lcr_sid);
|
|
||||||
ALTER TABLE lcr_routes ADD FOREIGN KEY lcr_sid_idxfk (lcr_sid) REFERENCES lcr (lcr_sid);
|
|
||||||
|
|
||||||
CREATE INDEX lcr_sid_idx ON lcr (lcr_sid);
|
|
||||||
ALTER TABLE lcr ADD FOREIGN KEY default_carrier_set_entry_sid_idxfk (default_carrier_set_entry_sid) REFERENCES lcr_carrier_set_entry (lcr_carrier_set_entry_sid);
|
|
||||||
|
|
||||||
CREATE INDEX service_provider_sid_idx ON lcr (service_provider_sid);
|
|
||||||
CREATE INDEX account_sid_idx ON lcr (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);
|
|
||||||
ALTER TABLE predefined_sip_gateways ADD FOREIGN KEY predefined_carrier_sid_idxfk (predefined_carrier_sid) REFERENCES predefined_carriers (predefined_carrier_sid);
|
|
||||||
|
|
||||||
CREATE INDEX predefined_smpp_gateway_sid_idx ON predefined_smpp_gateways (predefined_smpp_gateway_sid);
|
|
||||||
CREATE INDEX predefined_carrier_sid_idx ON predefined_smpp_gateways (predefined_carrier_sid);
|
|
||||||
ALTER TABLE predefined_smpp_gateways ADD FOREIGN KEY predefined_carrier_sid_idxfk_1 (predefined_carrier_sid) REFERENCES predefined_carriers (predefined_carrier_sid);
|
|
||||||
|
|
||||||
CREATE INDEX product_sid_idx ON products (product_sid);
|
|
||||||
CREATE INDEX account_product_sid_idx ON account_products (account_product_sid);
|
|
||||||
CREATE INDEX account_subscription_sid_idx ON account_products (account_subscription_sid);
|
|
||||||
ALTER TABLE account_products ADD FOREIGN KEY account_subscription_sid_idxfk (account_subscription_sid) REFERENCES account_subscriptions (account_subscription_sid);
|
|
||||||
|
|
||||||
ALTER TABLE account_products ADD FOREIGN KEY product_sid_idxfk (product_sid) REFERENCES products (product_sid);
|
|
||||||
|
|
||||||
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_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 api_key_sid_idx ON api_keys (api_key_sid);
|
||||||
CREATE INDEX account_sid_idx ON api_keys (account_sid);
|
CREATE INDEX account_sid_idx ON api_keys (account_sid);
|
||||||
ALTER TABLE api_keys ADD FOREIGN KEY account_sid_idxfk_6 (account_sid) REFERENCES accounts (account_sid);
|
ALTER TABLE api_keys ADD FOREIGN KEY account_sid_idxfk_1 (account_sid) REFERENCES accounts (account_sid);
|
||||||
|
|
||||||
CREATE INDEX service_provider_sid_idx ON api_keys (service_provider_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);
|
ALTER TABLE api_keys ADD FOREIGN KEY service_provider_sid_idxfk (service_provider_sid) REFERENCES service_providers (service_provider_sid);
|
||||||
|
|
||||||
CREATE INDEX sbc_addresses_idx_host_port ON sbc_addresses (ipv4,port);
|
|
||||||
|
|
||||||
CREATE INDEX sbc_address_sid_idx ON sbc_addresses (sbc_address_sid);
|
|
||||||
CREATE INDEX service_provider_sid_idx ON sbc_addresses (service_provider_sid);
|
|
||||||
ALTER TABLE sbc_addresses ADD FOREIGN KEY service_provider_sid_idxfk_1 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
|
|
||||||
|
|
||||||
CREATE INDEX ms_teams_tenant_sid_idx ON ms_teams_tenants (ms_teams_tenant_sid);
|
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 service_provider_sid_idxfk_1 (service_provider_sid) REFERENCES service_providers (service_provider_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 account_sid_idxfk_2 (account_sid) REFERENCES accounts (account_sid);
|
||||||
|
|
||||||
ALTER TABLE ms_teams_tenants ADD FOREIGN KEY application_sid_idxfk_1 (application_sid) REFERENCES applications (application_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 tenant_fqdn_idx ON ms_teams_tenants (tenant_fqdn);
|
||||||
CREATE INDEX service_provider_sid_idx ON service_provider_limits (service_provider_sid);
|
CREATE INDEX sbc_addresses_idx_host_port ON sbc_addresses (ipv4,port);
|
||||||
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 sbc_address_sid_idx ON sbc_addresses (sbc_address_sid);
|
||||||
CREATE INDEX smpp_address_sid_idx ON smpp_addresses (smpp_address_sid);
|
CREATE INDEX service_provider_sid_idx ON sbc_addresses (service_provider_sid);
|
||||||
CREATE INDEX service_provider_sid_idx ON smpp_addresses (service_provider_sid);
|
ALTER TABLE sbc_addresses ADD FOREIGN KEY service_provider_sid_idxfk_2 (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 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_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_8 (account_sid) REFERENCES accounts (account_sid);
|
|
||||||
|
|
||||||
CREATE INDEX user_sid_idx ON users (user_sid);
|
CREATE INDEX user_sid_idx ON users (user_sid);
|
||||||
CREATE INDEX email_idx ON users (email);
|
CREATE INDEX name_idx ON users (name);
|
||||||
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_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_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 voip_carrier_sid_idx ON voip_carriers (voip_carrier_sid);
|
||||||
CREATE INDEX account_sid_idx ON voip_carriers (account_sid);
|
CREATE INDEX name_idx ON voip_carriers (name);
|
||||||
ALTER TABLE voip_carriers ADD FOREIGN KEY account_sid_idxfk_10 (account_sid) REFERENCES accounts (account_sid);
|
ALTER TABLE voip_carriers ADD FOREIGN KEY account_sid_idxfk_3 (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_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);
|
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);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX phone_numbers_unique_idx_voip_carrier_number ON phone_numbers (number,voip_carrier_sid);
|
|
||||||
|
|
||||||
CREATE INDEX phone_number_sid_idx ON phone_numbers (phone_number_sid);
|
CREATE INDEX phone_number_sid_idx ON phone_numbers (phone_number_sid);
|
||||||
CREATE INDEX number_idx ON phone_numbers (number);
|
|
||||||
CREATE INDEX voip_carrier_sid_idx ON phone_numbers (voip_carrier_sid);
|
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 voip_carrier_sid_idxfk (voip_carrier_sid) REFERENCES voip_carriers (voip_carrier_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 account_sid_idxfk_4 (account_sid) REFERENCES accounts (account_sid);
|
||||||
|
|
||||||
ALTER TABLE phone_numbers ADD FOREIGN KEY application_sid_idxfk_3 (application_sid) REFERENCES applications (application_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);
|
CREATE INDEX webhook_sid_idx ON webhooks (webhook_sid);
|
||||||
ALTER TABLE phone_numbers ADD FOREIGN KEY service_provider_sid_idxfk_8 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
|
CREATE UNIQUE INDEX sip_gateway_idx_hostport ON sip_gateways (ipv4,port);
|
||||||
|
|
||||||
CREATE INDEX sip_gateway_idx_hostport ON sip_gateways (ipv4,port);
|
ALTER TABLE sip_gateways ADD FOREIGN KEY voip_carrier_sid_idxfk_1 (voip_carrier_sid) REFERENCES voip_carriers (voip_carrier_sid);
|
||||||
|
|
||||||
CREATE INDEX voip_carrier_sid_idx ON sip_gateways (voip_carrier_sid);
|
|
||||||
ALTER TABLE sip_gateways ADD FOREIGN KEY voip_carrier_sid_idxfk_2 (voip_carrier_sid) REFERENCES voip_carriers (voip_carrier_sid);
|
|
||||||
|
|
||||||
ALTER TABLE lcr_carrier_set_entry ADD FOREIGN KEY lcr_route_sid_idxfk (lcr_route_sid) REFERENCES lcr_routes (lcr_route_sid);
|
ALTER TABLE lcr_carrier_set_entry ADD FOREIGN KEY lcr_route_sid_idxfk (lcr_route_sid) REFERENCES lcr_routes (lcr_route_sid);
|
||||||
|
|
||||||
ALTER TABLE lcr_carrier_set_entry ADD FOREIGN KEY voip_carrier_sid_idxfk_3 (voip_carrier_sid) REFERENCES voip_carriers (voip_carrier_sid);
|
ALTER TABLE lcr_carrier_set_entry ADD FOREIGN KEY voip_carrier_sid_idxfk_2 (voip_carrier_sid) REFERENCES voip_carriers (voip_carrier_sid);
|
||||||
|
|
||||||
CREATE INDEX webhook_sid_idx ON webhooks (webhook_sid);
|
|
||||||
CREATE UNIQUE INDEX applications_idx_name ON applications (account_sid,name);
|
CREATE UNIQUE INDEX applications_idx_name ON applications (account_sid,name);
|
||||||
|
|
||||||
CREATE INDEX application_sid_idx ON applications (application_sid);
|
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_9 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
|
|
||||||
|
|
||||||
CREATE INDEX account_sid_idx ON applications (account_sid);
|
CREATE INDEX account_sid_idx ON applications (account_sid);
|
||||||
ALTER TABLE applications ADD FOREIGN KEY account_sid_idxfk_12 (account_sid) REFERENCES accounts (account_sid);
|
ALTER TABLE applications ADD FOREIGN KEY account_sid_idxfk_5 (account_sid) REFERENCES accounts (account_sid);
|
||||||
|
|
||||||
ALTER TABLE applications ADD FOREIGN KEY call_hook_sid_idxfk (call_hook_sid) REFERENCES webhooks (webhook_sid);
|
ALTER TABLE applications ADD FOREIGN KEY call_hook_sid_idxfk (call_hook_sid) REFERENCES webhooks (webhook_sid);
|
||||||
|
|
||||||
ALTER TABLE applications ADD FOREIGN KEY call_status_hook_sid_idxfk (call_status_hook_sid) REFERENCES webhooks (webhook_sid);
|
ALTER TABLE applications ADD FOREIGN KEY call_status_hook_sid_idxfk (call_status_hook_sid) REFERENCES webhooks (webhook_sid);
|
||||||
|
|
||||||
ALTER TABLE applications ADD FOREIGN KEY messaging_hook_sid_idxfk (messaging_hook_sid) REFERENCES webhooks (webhook_sid);
|
|
||||||
|
|
||||||
CREATE INDEX service_provider_sid_idx ON service_providers (service_provider_sid);
|
CREATE INDEX service_provider_sid_idx ON service_providers (service_provider_sid);
|
||||||
CREATE INDEX name_idx ON service_providers (name);
|
CREATE INDEX name_idx ON service_providers (name);
|
||||||
CREATE INDEX root_domain_idx ON service_providers (root_domain);
|
CREATE INDEX root_domain_idx ON service_providers (root_domain);
|
||||||
@@ -695,13 +248,10 @@ ALTER TABLE service_providers ADD FOREIGN KEY registration_hook_sid_idxfk (regis
|
|||||||
CREATE INDEX account_sid_idx ON accounts (account_sid);
|
CREATE INDEX account_sid_idx ON accounts (account_sid);
|
||||||
CREATE INDEX sip_realm_idx ON accounts (sip_realm);
|
CREATE INDEX sip_realm_idx ON accounts (sip_realm);
|
||||||
CREATE INDEX service_provider_sid_idx ON accounts (service_provider_sid);
|
CREATE INDEX service_provider_sid_idx ON accounts (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 service_provider_sid_idxfk_3 (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);
|
ALTER TABLE accounts ADD FOREIGN KEY registration_hook_sid_idxfk_1 (registration_hook_sid) REFERENCES webhooks (webhook_sid);
|
||||||
|
|
||||||
ALTER TABLE accounts ADD FOREIGN KEY queue_event_hook_sid_idxfk (queue_event_hook_sid) REFERENCES webhooks (webhook_sid);
|
|
||||||
|
|
||||||
ALTER TABLE accounts ADD FOREIGN KEY device_calling_application_sid_idxfk (device_calling_application_sid) REFERENCES applications (application_sid);
|
ALTER TABLE accounts ADD FOREIGN KEY device_calling_application_sid_idxfk (device_calling_application_sid) REFERENCES applications (application_sid);
|
||||||
|
|
||||||
ALTER TABLE accounts ADD FOREIGN KEY siprec_hook_sid_idxfk (siprec_hook_sid) REFERENCES applications (application_sid);
|
SET FOREIGN_KEY_CHECKS=1;
|
||||||
SET FOREIGN_KEY_CHECKS=1;
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
DROP DATABASE jambones_test;
|
DROP DATABASE jambones_test;
|
||||||
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'jambones_test'@'%';
|
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'jambones_test'@'localhost';
|
||||||
DROP USER 'jambones_test'@'%';
|
DROP USER 'jambones_test'@'localhost';
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
const test = require('tape');
|
|
||||||
const { sippUac } = require('./sipp')('test_fs');
|
|
||||||
const bent = require('bent');
|
|
||||||
const getJSON = bent('json')
|
|
||||||
const clearModule = require('clear-module');
|
|
||||||
const {provisionCallHook} = require('./utils')
|
|
||||||
|
|
||||||
const sleepFor = (ms) => new Promise((r) => setTimeout(r, 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('\'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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
await provisionCallHook(from, verbs);
|
|
||||||
|
|
||||||
// THEN
|
|
||||||
const p = sippUac('uas-dial.xml', '172.38.0.10', undefined, undefined, 2);
|
|
||||||
await sleepFor(1000);
|
|
||||||
|
|
||||||
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 sleepFor(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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
await 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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
await 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,104 +1,55 @@
|
|||||||
version: '3.9'
|
version: '3'
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
fs:
|
sbc-inbound:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
ipam:
|
ipam:
|
||||||
config:
|
config:
|
||||||
- subnet: 172.38.0.0/16
|
- subnet: 172.38.0.0/16
|
||||||
|
|
||||||
services:
|
services:
|
||||||
mysql:
|
sbc:
|
||||||
image: mysql:5.7
|
image: drachtio/drachtio-server:latest
|
||||||
platform: linux/x86_64
|
command: drachtio --contact "sip:*;transport=udp" --loglevel debug --sofia-loglevel 9
|
||||||
ports:
|
ports:
|
||||||
- "3360:3306"
|
- "9060:9022/tcp"
|
||||||
environment:
|
|
||||||
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "mysqladmin" ,"ping", "-h", "127.0.0.1", "--protocol", "tcp"]
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
networks:
|
networks:
|
||||||
fs:
|
sbc-inbound:
|
||||||
ipv4_address: 172.38.0.5
|
ipv4_address: 172.38.0.10
|
||||||
|
|
||||||
|
appserver:
|
||||||
|
image: drachtio/sipp:latest
|
||||||
|
command: sipp -sf /tmp/uas.xml
|
||||||
|
volumes:
|
||||||
|
- ./scenarios:/tmp
|
||||||
|
tty: true
|
||||||
|
networks:
|
||||||
|
sbc-inbound:
|
||||||
|
ipv4_address: 172.38.0.11
|
||||||
|
|
||||||
|
auth-server:
|
||||||
|
image: jambonz/customer-auth-server:latest
|
||||||
|
command: npm start
|
||||||
|
ports:
|
||||||
|
- "4000:4000/tcp"
|
||||||
|
env_file: docker.env
|
||||||
|
networks:
|
||||||
|
sbc-inbound:
|
||||||
|
ipv4_address: 172.38.0.12
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:5-alpine
|
image: redis:5-alpine
|
||||||
ports:
|
ports:
|
||||||
- "16379:6379/tcp"
|
- "16379:6379/tcp"
|
||||||
depends_on:
|
|
||||||
- mysql
|
|
||||||
networks:
|
networks:
|
||||||
fs:
|
sbc-inbound:
|
||||||
ipv4_address: 172.38.0.6
|
ipv4_address: 172.38.0.13
|
||||||
|
|
||||||
docker-host:
|
rtpengine:
|
||||||
image: qoomon/docker-host
|
image: drachtio/rtpengine:latest
|
||||||
cap_add: [ 'NET_ADMIN', 'NET_RAW' ]
|
|
||||||
mem_limit: 8M
|
|
||||||
restart: on-failure
|
|
||||||
networks:
|
|
||||||
fs:
|
|
||||||
ipv4_address: 172.38.0.7
|
|
||||||
|
|
||||||
drachtio:
|
|
||||||
image: drachtio/drachtio-server:0.8.24
|
|
||||||
restart: always
|
|
||||||
command: drachtio --contact "sip:*;transport=udp" --mtu 4096 --address 0.0.0.0 --port 9022
|
|
||||||
ports:
|
ports:
|
||||||
- "9060:9022/tcp"
|
- "12222:22222/udp"
|
||||||
networks:
|
networks:
|
||||||
fs:
|
sbc-inbound:
|
||||||
ipv4_address: 172.38.0.50
|
ipv4_address: 172.38.0.14
|
||||||
depends_on:
|
|
||||||
mysql:
|
|
||||||
condition: service_healthy
|
|
||||||
freeswitch:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
freeswitch:
|
|
||||||
image: drachtio/drachtio-freeswitch-mrf:0.6.1
|
|
||||||
restart: always
|
|
||||||
command: freeswitch --rtp-range-start 20000 --rtp-range-end 20100
|
|
||||||
environment:
|
|
||||||
GOOGLE_APPLICATION_CREDENTIALS: /opt/credentials/gcp.json
|
|
||||||
ports:
|
|
||||||
- "8022:8021/tcp"
|
|
||||||
volumes:
|
|
||||||
- /tmp:/tmp
|
|
||||||
- ./credentials:/opt/credentials
|
|
||||||
healthcheck:
|
|
||||||
test: ['CMD', 'fs_cli' ,'-p', 'JambonzR0ck$$', '-x', '"sofia status"']
|
|
||||||
timeout: 5s
|
|
||||||
retries: 15
|
|
||||||
networks:
|
|
||||||
fs:
|
|
||||||
ipv4_address: 172.38.0.51
|
|
||||||
|
|
||||||
webhook-scaffold:
|
|
||||||
image: jambonz/webhook-test-scaffold:latest
|
|
||||||
ports:
|
|
||||||
- "3100:3000/tcp"
|
|
||||||
volumes:
|
|
||||||
- ./test-apps:/tmp
|
|
||||||
networks:
|
|
||||||
fs:
|
|
||||||
ipv4_address: 172.38.0.60
|
|
||||||
|
|
||||||
influxdb:
|
|
||||||
image: influxdb:1.8
|
|
||||||
ports:
|
|
||||||
- "8086:8086"
|
|
||||||
networks:
|
|
||||||
fs:
|
|
||||||
ipv4_address: 172.38.0.90
|
|
||||||
|
|
||||||
squid:
|
|
||||||
image: ubuntu/squid:edge
|
|
||||||
ports:
|
|
||||||
- "3128:3128"
|
|
||||||
volumes:
|
|
||||||
- ./configuration/squid.conf:/etc/squid/squid.conf
|
|
||||||
networks:
|
|
||||||
fs:
|
|
||||||
ipv4_address: 172.38.0.91
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
const test = require('tape') ;
|
const test = require('tape').test ;
|
||||||
const exec = require('child_process').exec ;
|
const exec = require('child_process').exec ;
|
||||||
|
const async = require('async');
|
||||||
|
|
||||||
test('starting docker network..takes a bit for mysql and freeswitch to come up..patience..', (t) => {
|
test('starting docker network..', (t) => {
|
||||||
exec(`docker-compose -f ${__dirname}/docker-compose-testbed.yaml up -d`, (err, stdout, stderr) => {
|
exec(`docker-compose -f ${__dirname}/docker-compose-testbed.yaml up -d`, (err, stdout, stderr) => {
|
||||||
t.pass('docker network is up');
|
|
||||||
t.end(err);
|
t.end(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user