mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2026-02-10 08:21:33 +00:00
Compare commits
37 Commits
v0.7.5-rc1
...
v0.7.6-rc5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3298918322 | ||
|
|
f068aa5390 | ||
|
|
6e6ab56163 | ||
|
|
91204955c9 | ||
|
|
bc3552dda7 | ||
|
|
d459be2942 | ||
|
|
1c5c76de61 | ||
|
|
cb6817449d | ||
|
|
ffa006225b | ||
|
|
11d9a13ac7 | ||
|
|
21d5af367b | ||
|
|
2882fa2d0a | ||
|
|
a035b67e6c | ||
|
|
6979affb86 | ||
|
|
bb9c3a8df0 | ||
|
|
92fa3c249c | ||
|
|
7f808c6107 | ||
|
|
f95524863d | ||
|
|
aceaa5b7da | ||
|
|
7d57c85153 | ||
|
|
9aa0df256d | ||
|
|
627c38899f | ||
|
|
bdb40b3aa0 | ||
|
|
12ad7e556f | ||
|
|
05d6c8d467 | ||
|
|
5e9407ff4e | ||
|
|
e4fefe8f44 | ||
|
|
f7aac33af4 | ||
|
|
dc1d8de396 | ||
|
|
5be5b6d05d | ||
|
|
f51211b407 | ||
|
|
7f0e373e5f | ||
|
|
c3e5ffa52d | ||
|
|
0ee13fb794 | ||
|
|
4e84098036 | ||
|
|
6d34850dc6 | ||
|
|
76ff1835a6 |
21
Dockerfile
21
Dockerfile
@@ -1,10 +1,23 @@
|
||||
FROM node:lts-slim
|
||||
FROM --platform=linux/amd64 node:18.6.0-alpine as base
|
||||
|
||||
RUN apk --update --no-cache add --virtual .builds-deps build-base python3
|
||||
|
||||
WORKDIR /opt/app/
|
||||
|
||||
FROM base as build
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN npm ci
|
||||
RUN npm prune
|
||||
COPY . /opt/app
|
||||
|
||||
COPY . .
|
||||
|
||||
FROM base
|
||||
|
||||
COPY --from=build /opt/app /opt/app/
|
||||
|
||||
ARG NODE_ENV
|
||||
|
||||
ENV NODE_ENV $NODE_ENV
|
||||
|
||||
CMD [ "npm", "start" ]
|
||||
CMD [ "node", "app.js" ]
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
This application implements the core feature server of the jambones platform.
|
||||
|
||||
> Note: If you are a developer looking to work on the code please read our [how-to for that](./docs/contributing.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is provided via environment variables:
|
||||
@@ -84,7 +86,5 @@ module.exports = {
|
||||
```
|
||||
|
||||
#### Running the test suite
|
||||
The test suite currently only consists of JSON-parsing unit tests. A full end-to-end sip test suite should be added.
|
||||
```
|
||||
npm test
|
||||
```
|
||||
|
||||
Please [see this]](./docs/contributing.md#run-the-regression-test-suite).
|
||||
9
app.js
9
app.js
@@ -28,6 +28,7 @@ installSrfLocals(srf, logger);
|
||||
const {
|
||||
initLocals,
|
||||
createRootSpan,
|
||||
handleSipRec,
|
||||
getAccountDetails,
|
||||
normalizeNumbers,
|
||||
retrieveApplication,
|
||||
@@ -46,6 +47,7 @@ Object.assign(app.locals, {
|
||||
const httpRoutes = require('./lib/http-routes');
|
||||
|
||||
const InboundCallSession = require('./lib/session/inbound-call-session');
|
||||
const SipRecCallSession = require('./lib/session/siprec-call-session');
|
||||
|
||||
if (process.env.DRACHTIO_HOST) {
|
||||
srf.connect({host: process.env.DRACHTIO_HOST, port: process.env.DRACHTIO_PORT, secret: process.env.DRACHTIO_SECRET });
|
||||
@@ -68,14 +70,17 @@ if (process.env.NODE_ENV === 'test') {
|
||||
srf.use('invite', [
|
||||
initLocals,
|
||||
createRootSpan,
|
||||
handleSipRec,
|
||||
getAccountDetails,
|
||||
normalizeNumbers,
|
||||
retrieveApplication,
|
||||
invokeWebCallback
|
||||
]);
|
||||
|
||||
srf.invite((req, res) => {
|
||||
const session = new InboundCallSession(req, res);
|
||||
srf.invite(async(req, res) => {
|
||||
const isSipRec = !!req.locals.siprec;
|
||||
const session = isSipRec ? new SipRecCallSession(req, res) : new InboundCallSession(req, res);
|
||||
if (isSipRec) await session.answerSipRecCall();
|
||||
session.exec();
|
||||
});
|
||||
|
||||
|
||||
52
data/example-voicemail-greetings.json
Normal file
52
data/example-voicemail-greetings.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"en-US": [
|
||||
"call has been forwarded",
|
||||
"at the beep",
|
||||
"at the tone",
|
||||
"leave a message",
|
||||
"leave me a message",
|
||||
"not available right now",
|
||||
"not available to take your call",
|
||||
"can't take your call",
|
||||
"I will get back to you",
|
||||
"I'll get back to you",
|
||||
"we will get back to you",
|
||||
"we are unable",
|
||||
"we are not available"
|
||||
],
|
||||
"es-ES": [
|
||||
"le pasamos la llamada",
|
||||
"después del bip",
|
||||
"después del tono",
|
||||
"deja un mensaje",
|
||||
"déjame un mensaje",
|
||||
"no estamos disponibles",
|
||||
"no estoy disponible",
|
||||
"ahora no puedo",
|
||||
"no puedo contestar",
|
||||
"no le puedo contestar",
|
||||
"me pondré en contacto",
|
||||
"nos pondremos en contacto",
|
||||
"ahora no estamos disponibles",
|
||||
"no estamos disponibles"
|
||||
],
|
||||
"ca-ES": [
|
||||
"passem la seva trucada",
|
||||
"després del bip",
|
||||
"després del to",
|
||||
"deixi un missatge",
|
||||
"deixa un missatge",
|
||||
"deixim un missatge",
|
||||
"no estem disponibles",
|
||||
"no estem a l'oficina",
|
||||
"no estic disponible",
|
||||
"ara no puc",
|
||||
"no puc contestar",
|
||||
"no puc respondre",
|
||||
"no li puc respondre",
|
||||
"em posaré en contacte",
|
||||
"ens posarem en contacto",
|
||||
"ara no estem disponibles",
|
||||
"no hi som"
|
||||
],
|
||||
}
|
||||
123
docs/contributing.md
Normal file
123
docs/contributing.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Contributors are welcome!
|
||||
|
||||
So, you want to hack on jambonz? Maybe add some features, maybe help fix some bugs? Awesome, welcome aboard!
|
||||
|
||||
This brief document should get you started. Here you will find instructions showing how to set up your laptop to run the regression test suite (which you should always run before committing any changes), as well as some basic info on the structure of the code.
|
||||
|
||||
## Getting oriented
|
||||
|
||||
First of all, you are in the right place to begin hacking on jambonz. The jambonz-feature-server app is kinda the center of the universe for jambonz. Most of the core logic in jambonz is implemented here: things like the [webhook verbs](../lib/tasks), [session management](../lib/session), and the [client-side webhook implementation](../lib/utils/http-requestor.js). A common thing you might want to do, for instance, is to add support for an all-new verb, and this code base is where would do that.
|
||||
|
||||
This jambonz-feature-server app works together quite closely with a [drachtio server](https://github.com/drachtio/drachtio-server) and a Freeswitch. In fact, these three components are bundled together into a single VM/instance (or a Deployment, in Kubernetes) that we more generally refer to as "Feature Server". The Feature Server is a horizontally-scalable unit that is deployed behind the public-facing SBC elements of a jambonz cluster (the SBC is itself a separately scalable unit). The drachtio-server handles the SIP signaling, the Freeswitch handles media operations and speech vendor integration, and the jambonz-feature-server app orchestrates all of it via the use of [drachtio-srf](https://github.com/drachtio/drachtio-srf) and [drachtio-fsmrf](https://github.com/drachtio/drachtio-fsmrf).
|
||||
|
||||
## How to do things
|
||||
|
||||
First of all, please join our [slack channel](https://joinslack.jambonz.org) in order to coordinate with us on the work, i.e. to notify us of what you are doing and make sure that no one else is already working on the same thing.
|
||||
|
||||
To prepare to make changes, please fork the repo to your own Github account, make changes, test them on your own running jambonz cluster, then run the regression test suite and lint check before giving us a PR.
|
||||
|
||||
### lint
|
||||
|
||||
We have some opinionated conventions that you must follow - see our [eslintrc.json](../.eslintrc.json) for details. Make sure your code passes by running:
|
||||
|
||||
```bash
|
||||
npm run jslint
|
||||
```
|
||||
|
||||
### test suite
|
||||
|
||||
#### Generate speech credentials and create run-tests.sh
|
||||
|
||||
The test suite also requires you to provide speech credentials for both GCP and AWS. You will want to create a new file named `run-tests.sh` in the project folder. Make the file executable and then copy in the text below, substituting your speech credentials where indicated:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
GCP_JSON_KEY='{"type":"service_account","project_id":"...etc"}' \
|
||||
AWS_ACCESS_KEY_ID='your-aws-access-key-id' \
|
||||
AWS_SECRET_ACCESS_KEY='your-aws-secret-access-key' \
|
||||
AWS_REGION='us-east-1' \
|
||||
JWT_SECRET='foobar' \
|
||||
npm test
|
||||
```
|
||||
>> Note: The project's .gitignore file prevents this file from being sent to Github, so you do not need to worry about exposing your credentials. Just make sure you name if run-tests.sh and create it in the project folder
|
||||
|
||||
The GCP credential is the JSON service key in stringified format.
|
||||
|
||||
#### Install Docker
|
||||
|
||||
The test suite ralso equires [Docker](https://www.docker.com/) and docker-compose to be installed on your laptop. Docker is used to set up a network with all of the elements required to test the jambonz-feature-server in a black-box type of fashion.
|
||||
|
||||
Once you have docker installed, you can optionally make sure everything Docker-wise is working properly by running this command from the project folder:
|
||||
|
||||
```bash
|
||||
docker-compose -f test/docker-compose-testbed.yaml up -d
|
||||
```
|
||||
|
||||
This may take several minutes to complete, mainly because the mysql schema needs to be installed and seeded, but if successful the output should look like this:
|
||||
|
||||
```bash
|
||||
$ docker-compose -f test/docker-compose-testbed.yaml up -d
|
||||
Creating network "test_fs" with driver "bridge"
|
||||
Creating test_webhook-transcribe_1 ... done
|
||||
Creating test_webhook-decline_1 ... done
|
||||
Creating test_mysql_1 ... done
|
||||
Creating test_docker-host_1 ... done
|
||||
Creating test_webhook-gather_1 ... done
|
||||
Creating test_webhook-say_1 ... done
|
||||
Creating test_freeswitch_1 ... done
|
||||
Creating test_influxdb_1 ... done
|
||||
Creating test_redis_1 ... done
|
||||
Creating test_drachtio_1 ... done
|
||||
```
|
||||
|
||||
At that point, you can run `docker ps` to see all of the containers running
|
||||
|
||||
```bash
|
||||
docker ps
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
abbc3594f390 drachtio/drachtio-server:latest "/entrypoint.sh drac…" About a minute ago Up About a minute 0.0.0.0:9060->9022/tcp test_drachtio_1
|
||||
1f384a274f87 redis:5-alpine "docker-entrypoint.s…" 2 minutes ago Up 2 minutes 0.0.0.0:16379->6379/tcp test_redis_1
|
||||
78d0bb6ec9b1 influxdb:1.8 "/entrypoint.sh infl…" 2 minutes ago Up 2 minutes 0.0.0.0:8086->8086/tcp test_influxdb_1
|
||||
9616ff790709 jambonz/webhook-test-scaffold:latest "/entrypoint.sh" 2 minutes ago Up 2 minutes 0.0.0.0:3102->3000/tcp test_webhook-gather_1
|
||||
7323ab273ff4 drachtio/drachtio-freeswitch-mrf:v1.10.1-full "/entrypoint.sh free…" 2 minutes ago Up 2 minutes (healthy) 0.0.0.0:8022->8021/tcp test_freeswitch_1
|
||||
e45e7d28dbc7 mysql:5.7 "docker-entrypoint.s…" 2 minutes ago Up 2 minutes (healthy) 33060/tcp, 0.0.0.0:3360->3306/tcp test_mysql_1
|
||||
b626e5f3067e qoomon/docker-host "/entrypoint.sh" 2 minutes ago Up 2 minutes test_docker-host_1
|
||||
b0a94b5e8941 jambonz/webhook-test-scaffold:latest "/entrypoint.sh" 2 minutes ago Up 2 minutes 0.0.0.0:3101->3000/tcp test_webhook-say_1
|
||||
f80adda48eb5 jambonz/webhook-test-scaffold:latest "/entrypoint.sh" 2 minutes ago Up 2 minutes 0.0.0.0:3103->3000/tcp test_webhook-transcribe_1
|
||||
223db4a9c670 jambonz/webhook-test-scaffold:latest "/entrypoint.sh" 2 minutes ago Up 2 minutes 0.0.0.0:3100->3000/tcp test_webhook-decline_1
|
||||
```
|
||||
|
||||
#### Run the regression test suite
|
||||
|
||||
The test suite has a dependency that the mysql client is installed on your laptop/machine where the test will be run. This is needed in order to seed the mysql database that is running in the docker network.
|
||||
|
||||
Assuming you have installed the mysql client, and done the above steps, you should now be able to run the tests:
|
||||
|
||||
```bash
|
||||
./run-tests.sh
|
||||
```
|
||||
|
||||
If the docker network has not been started (as described above) it will start now, and this will take a minute or two. Otherwise, the test suite will start running immediately.
|
||||
|
||||
In evaluating the test results, be advised that the output is fairly verbose, and also in the process of shutting down once the tests are complete you will see a bunch of errors from redis (`@jambonz/realtimedb-helpers - redis error`). You can ignore these errors, they are just spit out by jambonz-feature-server as the test environment is torn down and it tries and fails to reconnect to redis.
|
||||
|
||||
The final output will indicate the number of tests run and passed:
|
||||
|
||||
```bash
|
||||
1..28
|
||||
# tests 28
|
||||
# pass 28
|
||||
|
||||
# ok
|
||||
```
|
||||
|
||||
#### Adding your own tests
|
||||
|
||||
Running a successful regression test means you haven't broken anything - Great!
|
||||
|
||||
It doesn't, of course, mean that your shiny new feature or bugfix works. Adding a new test case to the suite is (unfortunately) non-trivial. We will add more documentation in the future with a how-to guide on that, but be advised it does require knowledge of the SIP protocol and the [SIPp](http://sipp.sourceforge.net/doc/reference.html) tool.
|
||||
|
||||
For now, if you are unable to add tests to the regression suite, please do test your feature as thoroughly as you can on your own jambonz cluster before giving us a pull request.
|
||||
|
||||
|
||||
|
||||
@@ -84,7 +84,6 @@ router.post('/', async(req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* create endpoint for outdial */
|
||||
const ms = getFreeswitch();
|
||||
if (!ms) throw new Error('no available Freeswitch for outbound call creation');
|
||||
@@ -197,9 +196,10 @@ router.post('/', async(req, res) => {
|
||||
});
|
||||
cs.exec(req);
|
||||
|
||||
res.status(201).json({sid: cs.callSid});
|
||||
res.status(201).json({sid: cs.callSid, callId: inviteReq.get('Call-ID')});
|
||||
|
||||
sipLogger.info(`outbound REST call attempt to ${JSON.stringify(target)} has been sent`);
|
||||
sipLogger.info({sid: cs.callSid, callId: inviteReq.get('Call-ID')},
|
||||
`outbound REST call attempt to ${JSON.stringify(target)} has been sent`);
|
||||
},
|
||||
cbProvisional: (prov) => {
|
||||
const callStatus = prov.body ? CallStatus.EarlyMedia : CallStatus.Ringing;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const {CallDirection} = require('./utils/constants');
|
||||
const {CallDirection, AllowedSipRecVerbs} = require('./utils/constants');
|
||||
const {parseSiprecPayload} = require('./utils/siprec-utils');
|
||||
const CallInfo = require('./session/call-info');
|
||||
const HttpRequestor = require('./utils/http-requestor');
|
||||
const WsRequestor = require('./utils/ws-requestor');
|
||||
@@ -73,6 +74,35 @@ module.exports = function(srf, logger) {
|
||||
next();
|
||||
}
|
||||
|
||||
const handleSipRec = async(req, res, next) => {
|
||||
if (Array.isArray(req.payload) && req.payload.length > 1) {
|
||||
const {callId, logger} = req.locals;
|
||||
logger.debug({payload: req.payload}, 'handling siprec call');
|
||||
|
||||
try {
|
||||
const sdp = req.payload
|
||||
.find((p) => p.type === 'application/sdp')
|
||||
.content;
|
||||
const {sdp1, sdp2, ...metadata} = await parseSiprecPayload(req, logger);
|
||||
req.locals.calledNumber = metadata.caller.number;
|
||||
req.locals.callingNumber = metadata.callee.number;
|
||||
req.locals = {
|
||||
...req.locals,
|
||||
siprec: {
|
||||
metadata,
|
||||
sdp1,
|
||||
sdp2
|
||||
}
|
||||
};
|
||||
logger.info({callId, metadata, sdp}, 'successfully parsed SIPREC payload');
|
||||
} catch (err) {
|
||||
logger.info({callId}, 'Error parsing multipart payload');
|
||||
return res.send(503);
|
||||
}
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* retrieve account information for the incoming call
|
||||
*/
|
||||
@@ -101,7 +131,10 @@ module.exports = function(srf, logger) {
|
||||
* Within the system, we deal with E.164 numbers _without_ the leading '+
|
||||
*/
|
||||
function normalizeNumbers(req, res, next) {
|
||||
const logger = req.locals.logger;
|
||||
const {logger, siprec} = req.locals;
|
||||
|
||||
if (siprec) return next();
|
||||
|
||||
Object.assign(req.locals, {
|
||||
calledNumber: req.calledNumber,
|
||||
callingNumber: req.callingNumber
|
||||
@@ -225,7 +258,7 @@ module.exports = function(srf, logger) {
|
||||
*/
|
||||
async function invokeWebCallback(req, res, next) {
|
||||
const logger = req.locals.logger;
|
||||
const {rootSpan, application:app} = req.locals;
|
||||
const {rootSpan, siprec, application:app} = req.locals;
|
||||
let span;
|
||||
try {
|
||||
if (app.tasks) {
|
||||
@@ -261,6 +294,18 @@ module.exports = function(srf, logger) {
|
||||
});
|
||||
span.end();
|
||||
if (0 === app.tasks.length) throw new Error('no application provided');
|
||||
|
||||
if (siprec) {
|
||||
const tasks = app.tasks.filter((t) => AllowedSipRecVerbs.includes(t.name));
|
||||
if (0 === tasks.length) {
|
||||
logger.info({tasks: app.tasks}, 'no valid verbs in app found for an incoming siprec call');
|
||||
throw new Error('invalid verbs for incoming siprec call');
|
||||
}
|
||||
if (tasks.length < app.tasks.length) {
|
||||
logger.info('removing verbs that are not allowed for incoming siprec call');
|
||||
app.tasks = tasks;
|
||||
}
|
||||
}
|
||||
next();
|
||||
} catch (err) {
|
||||
span?.setAttributes({webhookStatus: err.statusCode});
|
||||
@@ -274,6 +319,7 @@ module.exports = function(srf, logger) {
|
||||
return {
|
||||
initLocals,
|
||||
createRootSpan,
|
||||
handleSipRec,
|
||||
getAccountDetails,
|
||||
normalizeNumbers,
|
||||
retrieveApplication,
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
const Emitter = require('events');
|
||||
const fs = require('fs');
|
||||
const {CallDirection, TaskPreconditions, CallStatus, TaskName, KillReason} = require('../utils/constants');
|
||||
const {
|
||||
CallDirection,
|
||||
TaskPreconditions,
|
||||
CallStatus,
|
||||
TaskName,
|
||||
KillReason,
|
||||
RecordState,
|
||||
AllowedSipRecVerbs
|
||||
} = require('../utils/constants');
|
||||
const moment = require('moment');
|
||||
const assert = require('assert');
|
||||
const sessionTracker = require('./session-tracker');
|
||||
@@ -54,6 +62,8 @@ class CallSession extends Emitter {
|
||||
|
||||
assert(rootSpan);
|
||||
|
||||
this._recordState = RecordState.RecordingOff;
|
||||
|
||||
this.tmpFiles = new Set();
|
||||
|
||||
if (!this.isSmsCallSession) {
|
||||
@@ -63,6 +73,10 @@ class CallSession extends Emitter {
|
||||
|
||||
if (!this.isConfirmCallSession && !this.isSmsCallSession && !this.isAdultingCallSession) {
|
||||
sessionTracker.add(this.callSid, this);
|
||||
|
||||
const {startAmd, stopAmd} = require('../utils/amd-utils')(logger);
|
||||
this.startAmd = startAmd;
|
||||
this.stopAmd = stopAmd;
|
||||
}
|
||||
|
||||
this._pool = srf.locals.dbHelpers.pool;
|
||||
@@ -85,6 +99,10 @@ class CallSession extends Emitter {
|
||||
return this.callInfo.direction;
|
||||
}
|
||||
|
||||
get applicationSid() {
|
||||
return this.callInfo.applicationSid;
|
||||
}
|
||||
|
||||
/**
|
||||
* SIP call-id for the call
|
||||
*/
|
||||
@@ -211,6 +229,13 @@ class CallSession extends Emitter {
|
||||
return this.constructor.name === 'ConfirmCallSession';
|
||||
}
|
||||
|
||||
/**
|
||||
* returns true if this session is a SipRecCallSession
|
||||
*/
|
||||
get isSipRecCallSession() {
|
||||
return this.constructor.name === 'SipRecCallSession';
|
||||
}
|
||||
|
||||
/**
|
||||
* returns true if this session is a SmsCallSession
|
||||
*/
|
||||
@@ -234,15 +259,162 @@ class CallSession extends Emitter {
|
||||
return this.rootSpan?.getTracingPropagation();
|
||||
}
|
||||
|
||||
get recordState() { return this._recordState; }
|
||||
|
||||
async notifyRecordOptions(opts) {
|
||||
const {action} = opts;
|
||||
this.logger.debug({opts}, 'CallSession:notifyRecordOptions');
|
||||
|
||||
/* if we have not answered yet, just save the details for later */
|
||||
if (!this.dlg) {
|
||||
if (action === 'startCallRecording') {
|
||||
this.recordOptions = opts;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* check validity of request */
|
||||
if (action == 'startCallRecording' && this.recordState !== RecordState.RecordingOff) {
|
||||
this.logger.info({recordState: this.recordState},
|
||||
'CallSession:notifyRecordOptions: recording is already started, ignoring request');
|
||||
return false;
|
||||
}
|
||||
if (action == 'stopCallRecording' && this.recordState === RecordState.RecordingOff) {
|
||||
this.logger.info({recordState: this.recordState},
|
||||
'CallSession:notifyRecordOptions: recording is already stopped, ignoring request');
|
||||
return false;
|
||||
}
|
||||
if (action == 'pauseCallRecording' && this.recordState !== RecordState.RecordingOn) {
|
||||
this.logger.info({recordState: this.recordState},
|
||||
'CallSession:notifyRecordOptions: cannot pause recording, ignoring request ');
|
||||
return false;
|
||||
}
|
||||
if (action == 'resumeCallRecording' && this.recordState !== RecordState.RecordingPaused) {
|
||||
this.logger.info({recordState: this.recordState},
|
||||
'CallSession:notifyRecordOptions: cannot resume recording, ignoring request ');
|
||||
return false;
|
||||
}
|
||||
|
||||
this.recordOptions = opts;
|
||||
|
||||
switch (action) {
|
||||
case 'startCallRecording':
|
||||
return await this.startRecording();
|
||||
case 'stopCallRecording':
|
||||
return await this.stopRecording();
|
||||
case 'pauseCallRecording':
|
||||
return await this.pauseRecording();
|
||||
case 'resumeCallRecording':
|
||||
return await this.resumeRecording();
|
||||
default:
|
||||
throw new Error(`invalid record action ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
async startRecording() {
|
||||
const {recordingID, siprecServerURL} = this.recordOptions;
|
||||
assert(this.dlg);
|
||||
this.logger.debug(`CallSession:startRecording - sending to ${siprecServerURL}`);
|
||||
try {
|
||||
const res = await this.dlg.request({
|
||||
method: 'INFO',
|
||||
headers: {
|
||||
'X-Reason': 'startCallRecording',
|
||||
'X-Srs-Url': siprecServerURL,
|
||||
'X-Srs-Recording-ID': recordingID,
|
||||
'X-Call-Sid': this.callSid,
|
||||
'X-Account-Sid': this.accountSid,
|
||||
'X-Application-Sid': this.applicationSid,
|
||||
}
|
||||
});
|
||||
if (res.status === 200) {
|
||||
this._recordState = RecordState.RecordingOn;
|
||||
return true;
|
||||
}
|
||||
this.logger.info(`CallSession:startRecording - ${res.status} failure sending to ${siprecServerURL}`);
|
||||
return false;
|
||||
} catch (err) {
|
||||
this.logger.info({err}, `CallSession:startRecording - failure sending to ${siprecServerURL}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async stopRecording() {
|
||||
assert(this.dlg);
|
||||
this.logger.debug('CallSession:stopRecording');
|
||||
try {
|
||||
const res = await this.dlg.request({
|
||||
method: 'INFO',
|
||||
headers: {
|
||||
'X-Reason': 'stopCallRecording',
|
||||
}
|
||||
});
|
||||
if (res.status === 200) {
|
||||
this._recordState = RecordState.RecordingOff;
|
||||
return true;
|
||||
}
|
||||
this.logger.info(`CallSession:stopRecording - ${res.status} failure`);
|
||||
return false;
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'CallSession:startRecording - failure sending');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async pauseRecording() {
|
||||
assert(this.dlg);
|
||||
this.logger.debug('CallSession:pauseRecording');
|
||||
try {
|
||||
const res = await this.dlg.request({
|
||||
method: 'INFO',
|
||||
headers: {
|
||||
'X-Reason': 'pauseCallRecording',
|
||||
}
|
||||
});
|
||||
if (res.status === 200) {
|
||||
this._recordState = RecordState.RecordingPaused;
|
||||
return true;
|
||||
}
|
||||
this.logger.info(`CallSession:pauseRecording - ${res.status} failure`);
|
||||
return false;
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'CallSession:pauseRecording - failure sending');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async resumeRecording() {
|
||||
assert(this.dlg);
|
||||
this.logger.debug('CallSession:resumeRecording');
|
||||
try {
|
||||
const res = await this.dlg.request({
|
||||
method: 'INFO',
|
||||
headers: {
|
||||
'X-Reason': 'resumeCallRecording',
|
||||
}
|
||||
});
|
||||
if (res.status === 200) {
|
||||
this._recordState = RecordState.RecordingOn;
|
||||
return true;
|
||||
}
|
||||
this.logger.info(`CallSession:resumeRecording - ${res.status} failure`);
|
||||
return false;
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'CallSession:resumeRecording - failure sending');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async enableBotMode(gather, autoEnable) {
|
||||
try {
|
||||
const t = normalizeJambones(this.logger, [gather]);
|
||||
this.backgroundGatherTask = makeTask(this.logger, t[0]);
|
||||
this.backgroundGatherTask
|
||||
.on('dtmf', this._clearTasks.bind(this))
|
||||
.on('vad', this._clearTasks.bind(this))
|
||||
.on('transcription', this._clearTasks.bind(this))
|
||||
.on('timeout', this._clearTasks.bind(this));
|
||||
.once('dtmf', this._clearTasks.bind(this))
|
||||
.once('vad', this._clearTasks.bind(this))
|
||||
.once('transcription', this._clearTasks.bind(this))
|
||||
.once('timeout', this._clearTasks.bind(this));
|
||||
this.logger.info({gather}, 'CallSession:enableBotMode - starting background gather');
|
||||
const resources = await this._evaluatePreconditions(this.backgroundGatherTask);
|
||||
const {span, ctx} = this.rootSpan.startChildSpan(`background-gather:${this.backgroundGatherTask.summary}`);
|
||||
@@ -724,6 +896,9 @@ class CallSession extends Emitter {
|
||||
const res = await this._lccSipRequest(opts, callSid);
|
||||
return {status: res.status, reason: res.reason};
|
||||
}
|
||||
else if (opts.record) {
|
||||
await this.notifyRecordOptions(opts.record);
|
||||
}
|
||||
|
||||
// whisper may be the only thing we are asked to do, or it may that
|
||||
// we are doing a whisper after having muted, paused reccording etc..
|
||||
@@ -741,6 +916,20 @@ class CallSession extends Emitter {
|
||||
this.logger.debug('CallSession:replaceApplication - ignoring because call is gone');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isSipRecCallSession) {
|
||||
const pruned = tasks.filter((t) => AllowedSipRecVerbs.includes(t.name));
|
||||
if (0 === pruned.length) {
|
||||
this.logger.info({tasks},
|
||||
'CallSession:replaceApplication - only config, transcribe and/or listen allowed on an incoming siprec call');
|
||||
return;
|
||||
}
|
||||
if (pruned.length < tasks.length) {
|
||||
this.logger.info(
|
||||
'CallSession:replaceApplication - removing verbs that are not allowed for incoming siprec call');
|
||||
tasks = pruned;
|
||||
}
|
||||
}
|
||||
this.tasks = tasks;
|
||||
this.taskIdx = 0;
|
||||
this.stackIdx++;
|
||||
@@ -912,11 +1101,20 @@ class CallSession extends Emitter {
|
||||
if (this.callGone) new Error(`${BADPRECONDITIONS}: call gone`);
|
||||
|
||||
if (this.ep) {
|
||||
if (task.earlyMedia === true || this.dlg) return this.ep;
|
||||
const resources = {ep: this.ep};
|
||||
if (task.earlyMedia === true || this.dlg) {
|
||||
return {
|
||||
...resources,
|
||||
...(this.isSipRecCallSession && {ep2: this.ep2})
|
||||
};
|
||||
}
|
||||
|
||||
// we are going from an early media connection to answer
|
||||
await this.propagateAnswer();
|
||||
return this.ep;
|
||||
return {
|
||||
...resources,
|
||||
...(this.isSipRecCallSession && {ep2: this.ep2})
|
||||
};
|
||||
}
|
||||
|
||||
// need to allocate an endpoint
|
||||
@@ -939,7 +1137,7 @@ class CallSession extends Emitter {
|
||||
if (this.direction === CallDirection.Inbound) {
|
||||
if (task.earlyMedia && !this.req.finalResponseSent) {
|
||||
this.res.send(183, {body: ep.local.sdp});
|
||||
return ep;
|
||||
return {ep};
|
||||
}
|
||||
this.logger.debug('propogating answer');
|
||||
await this.propagateAnswer();
|
||||
@@ -948,7 +1146,7 @@ class CallSession extends Emitter {
|
||||
// outbound call TODO
|
||||
}
|
||||
|
||||
return ep;
|
||||
return {ep};
|
||||
} catch (err) {
|
||||
if (err === CALLER_CANCELLED_ERR_MSG) {
|
||||
this.logger.error(err, 'caller canceled quickly before we could respond, ending call');
|
||||
@@ -973,7 +1171,7 @@ class CallSession extends Emitter {
|
||||
_evalStableCallPrecondition(task) {
|
||||
if (this.callGone) throw new Error(`${BADPRECONDITIONS}: call gone`);
|
||||
if (!this.dlg) throw new Error(`${BADPRECONDITIONS}: call was not answered`);
|
||||
return this.dlg;
|
||||
return {dlg: this.dlg};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1012,7 +1210,7 @@ class CallSession extends Emitter {
|
||||
* Hang up the call and free the media endpoint
|
||||
*/
|
||||
_clearResources() {
|
||||
for (const resource of [this.dlg, this.ep]) {
|
||||
for (const resource of [this.dlg, this.ep, this.ep2]) {
|
||||
if (resource && resource.connected) resource.destroy();
|
||||
}
|
||||
this.dlg = null;
|
||||
@@ -1076,6 +1274,9 @@ class CallSession extends Emitter {
|
||||
this.dlg.callSid = this.callSid;
|
||||
this.emit('callStatusChange', {sipStatus: 200, sipReason: 'OK', callStatus: CallStatus.InProgress});
|
||||
|
||||
if (this.recordOptions && this.recordState === RecordState.RecordingOff) {
|
||||
this.startRecording();
|
||||
}
|
||||
this.dlg.on('modify', this._onReinvite.bind(this));
|
||||
this.dlg.on('refer', this._onRefer.bind(this));
|
||||
|
||||
@@ -1258,7 +1459,7 @@ class CallSession extends Emitter {
|
||||
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||
this.logger.debug('CallSession: call terminated by jambones');
|
||||
this.rootSpan.setAttributes({'call.termination': 'hangup by jambonz'});
|
||||
origDestroy();
|
||||
origDestroy().catch((err) => this.logger.info({err}, 'CallSession - error destroying dialog'));
|
||||
if (this.wakeupResolver) {
|
||||
this.wakeupResolver({reason: 'session ended'});
|
||||
this.wakeupResolver = null;
|
||||
|
||||
58
lib/session/siprec-call-session.js
Normal file
58
lib/session/siprec-call-session.js
Normal file
@@ -0,0 +1,58 @@
|
||||
const InboundCallSession = require('./inbound-call-session');
|
||||
const {createSipRecPayload} = require('../utils/siprec-utils');
|
||||
const {CallStatus} = require('../utils/constants');
|
||||
/**
|
||||
* @classdesc Subclass of InboundCallSession. This represents a CallSession that is
|
||||
* established for an inbound SIPREC call.
|
||||
* @extends InboundCallSession
|
||||
*/
|
||||
class SipRecCallSession extends InboundCallSession {
|
||||
constructor(req, res) {
|
||||
super(req, res);
|
||||
|
||||
const {sdp1, sdp2, metadata} = req.locals.siprec;
|
||||
this.sdp1 = sdp1;
|
||||
this.sdp2 = sdp2;
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
async answerSipRecCall() {
|
||||
try {
|
||||
this.ms = this.getMS();
|
||||
let remoteSdp = this.sdp1.replace(/sendonly/, 'sendrecv');
|
||||
this.ep = await this.ms.createEndpoint({remoteSdp});
|
||||
//this.logger.debug({remoteSdp, localSdp: this.ep.local.sdp}, 'SipRecCallSession - allocated first endpoint');
|
||||
remoteSdp = this.sdp2.replace(/sendonly/, 'sendrecv');
|
||||
this.ep2 = await this.ms.createEndpoint({remoteSdp});
|
||||
//this.logger.debug({remoteSdp, localSdp: this.ep2.local.sdp}, 'SipRecCallSession - allocated second endpoint');
|
||||
await this.ep.bridge(this.ep2);
|
||||
const combinedSdp = await createSipRecPayload(this.ep.local.sdp, this.ep2.local.sdp, this.logger);
|
||||
/*
|
||||
this.logger.debug({
|
||||
combinedSdp
|
||||
}, 'SipRecCallSession:_answerSipRecCall - created SIPREC payload');
|
||||
*/
|
||||
this.dlg = await this.srf.createUAS(this.req, this.res, {
|
||||
headers: {
|
||||
'Content-Type': 'application/sdp',
|
||||
'X-Trace-ID': this.req.locals.traceId,
|
||||
'X-Call-Sid': this.req.locals.callSid
|
||||
},
|
||||
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;
|
||||
@@ -72,7 +72,7 @@ class Conference extends Task {
|
||||
get shouldRecord() { return this.record.path; }
|
||||
get isRecording() { return this.recordingInProgress; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
this.ep = ep;
|
||||
const dlg = cs.dlg;
|
||||
@@ -453,7 +453,7 @@ class Conference extends Task {
|
||||
this._playSession = null;
|
||||
break;
|
||||
}
|
||||
} while (!this.killed && this.conf_hold_status !== 'hold');
|
||||
} while (!this.killed && this.conf_hold_status === 'hold');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -551,7 +551,8 @@ class Conference extends Task {
|
||||
accountInfo: cs.accountInfo,
|
||||
memberId: this.memberId,
|
||||
confName: this.confName,
|
||||
tasks
|
||||
tasks,
|
||||
rootSpan: cs.rootSpan
|
||||
});
|
||||
await this._playSession.exec();
|
||||
this._playSession = null;
|
||||
|
||||
@@ -4,12 +4,11 @@ const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
class TaskConfig extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
[
|
||||
'synthesizer',
|
||||
'recognizer',
|
||||
'bargeIn'
|
||||
'bargeIn',
|
||||
'record'
|
||||
].forEach((k) => this[k] = this.data[k] || {});
|
||||
|
||||
if (this.bargeIn.enable) {
|
||||
@@ -27,7 +26,9 @@ class TaskConfig extends Task {
|
||||
});
|
||||
}
|
||||
if (this.bargeIn.sticky) this.autoEnable = true;
|
||||
this.preconditions = this.bargeIn.enable ? TaskPreconditions.Endpoint : TaskPreconditions.None;
|
||||
this.preconditions = (this.bargeIn.enable || this.record?.action) ?
|
||||
TaskPreconditions.Endpoint :
|
||||
TaskPreconditions.None;
|
||||
}
|
||||
|
||||
get name() { return TaskName.Config; }
|
||||
@@ -49,12 +50,19 @@ class TaskConfig extends Task {
|
||||
const s = `{${v},${l}}`;
|
||||
phrase.push(`set recognizer${s}`);
|
||||
}
|
||||
if (this.data.amd) phrase.push('enable amd');
|
||||
return `${this.name}{${phrase.join(',')}`;
|
||||
}
|
||||
|
||||
async exec(cs) {
|
||||
await super.exec(cs);
|
||||
|
||||
if (this.data.amd) {
|
||||
this.startAmd = cs.startAmd;
|
||||
this.stopAmd = cs.stopAmd;
|
||||
this.on('amd', this._onAmdEvent.bind(this, cs));
|
||||
}
|
||||
|
||||
if (this.hasSynthesizer) {
|
||||
cs.speechSynthesisVendor = this.synthesizer.vendor !== 'default'
|
||||
? this.synthesizer.vendor
|
||||
@@ -74,7 +82,15 @@ class TaskConfig extends Task {
|
||||
cs.speechRecognizerLanguage = this.recognizer.language !== 'default'
|
||||
? this.recognizer.language
|
||||
: cs.speechRecognizerLanguage;
|
||||
this.logger.info({recognizer: this.recognizer}, 'Config: updated recognizer');
|
||||
cs.isContinuousAsr = typeof this.recognizer.asrTimeout === 'number' ? true : false;
|
||||
if (cs.isContinuousAsr) {
|
||||
cs.asrTimeout = this.recognizer.asrTimeout;
|
||||
cs.asrDtmfTerminationDigit = this.recognizer.asrDtmfTerminationDigit;
|
||||
}
|
||||
this.logger.info({
|
||||
recognizer: this.recognizer,
|
||||
isContinuousAsr: cs.isContinuousAsr
|
||||
}, 'Config: updated recognizer');
|
||||
}
|
||||
if ('enable' in this.bargeIn) {
|
||||
if (this.gatherOpts) {
|
||||
@@ -92,11 +108,24 @@ class TaskConfig extends Task {
|
||||
cs.disableBotMode();
|
||||
}
|
||||
}
|
||||
if (this.record.action) {
|
||||
try {
|
||||
await cs.notifyRecordOptions(this.record);
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Config: error starting recording');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
}
|
||||
|
||||
_onAmdEvent(cs, evt) {
|
||||
this.logger.info({evt}, 'Config:_onAmdEvent');
|
||||
const {actionHook} = this.data.amd;
|
||||
this.performHook(cs, actionHook, evt);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskConfig;
|
||||
|
||||
@@ -23,7 +23,7 @@ class TaskDequeue extends Task {
|
||||
|
||||
get name() { return TaskName.Dequeue; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
this.ep = ep;
|
||||
this.queueName = `queue:${cs.accountSid}:${this.queueName}`;
|
||||
|
||||
@@ -134,7 +134,10 @@ class TaskDial extends Task {
|
||||
get name() { return TaskName.Dial; }
|
||||
|
||||
get canReleaseMedia() {
|
||||
return !process.env.ANCHOR_MEDIA_ALWAYS && !this.listenTask && !this.transcribeTask;
|
||||
return !process.env.ANCHOR_MEDIA_ALWAYS &&
|
||||
!this.listenTask &&
|
||||
!this.transcribeTask &&
|
||||
!this.startAmd;
|
||||
}
|
||||
|
||||
get summary() {
|
||||
@@ -158,6 +161,11 @@ class TaskDial extends Task {
|
||||
async exec(cs) {
|
||||
await super.exec(cs);
|
||||
try {
|
||||
if (this.data.amd) {
|
||||
this.startAmd = cs.startAmd;
|
||||
this.stopAmd = cs.stopAmd;
|
||||
this.on('amd', this._onAmdEvent.bind(this, cs));
|
||||
}
|
||||
if (cs.direction === CallDirection.Inbound) {
|
||||
await this._initializeInbound(cs);
|
||||
}
|
||||
@@ -181,6 +189,11 @@ class TaskDial extends Task {
|
||||
|
||||
async kill(cs, reason) {
|
||||
super.kill(cs);
|
||||
try {
|
||||
if (this.ep && this.ep.amd) this.stopAmd(this.ep, this);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'DialTask:kill - error stopping answering machine detectin');
|
||||
}
|
||||
if (this.dialMusic && this.epOther) {
|
||||
this.epOther.api('uuid_break', this.epOther.uuid)
|
||||
.catch((err) => this.logger.info(err, 'Error killing dialMusic'));
|
||||
@@ -203,8 +216,14 @@ class TaskDial extends Task {
|
||||
this.sd = null;
|
||||
}
|
||||
if (this.callSid) sessionTracker.remove(this.callSid);
|
||||
if (this.listenTask) await this.listenTask.kill(cs);
|
||||
if (this.transcribeTask) await this.transcribeTask.kill(cs);
|
||||
if (this.listenTask) {
|
||||
await this.listenTask.kill(cs);
|
||||
this.listenTask = null;
|
||||
}
|
||||
if (this.transcribeTask) {
|
||||
await this.transcribeTask.kill(cs);
|
||||
this.transcribeTask = null;
|
||||
}
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
@@ -226,10 +245,10 @@ class TaskDial extends Task {
|
||||
this.logger.debug('Dial:whisper executing tasks');
|
||||
while (tasks.length && !cs.callGone) {
|
||||
const task = tasks.shift();
|
||||
const {span, ctx} = this.startChildSpan(`whisper:${this.sayTask.summary}`);
|
||||
const {span, ctx} = this.startChildSpan(`whisper:${task.summary}`);
|
||||
task.span = span;
|
||||
task.ctx = ctx;
|
||||
await task.exec(cs, callSid === this.callSid ? this.ep : this.epOther);
|
||||
await task.exec(cs, callSid === this.callSid ? {ep: this.ep} : {ep: this.epOther});
|
||||
span.end();
|
||||
}
|
||||
this.logger.debug('Dial:whisper tasks complete');
|
||||
@@ -404,6 +423,11 @@ class TaskDial extends Task {
|
||||
this.logger.info(`Dial:_attemptCall: ring no answer timer ${this.timeout}s exceeded`);
|
||||
this.timerRing = null;
|
||||
this._killOutdials();
|
||||
this.result = {
|
||||
dialCallStatus: CallStatus.NoAnswer,
|
||||
dialSipStatus: 487
|
||||
};
|
||||
this.kill(cs);
|
||||
}, this.timeout * 1000);
|
||||
|
||||
this.span.setAttributes({'dial.target': JSON.stringify(this.target)});
|
||||
@@ -566,6 +590,7 @@ class TaskDial extends Task {
|
||||
* - save the dialog and endpoint
|
||||
* - clock the start time of the call,
|
||||
* - start a max call length timer (optionally)
|
||||
* - start answering machine detection (optionally)
|
||||
* - launch any nested tasks
|
||||
* - and establish a handler to clean up if the called party hangs up
|
||||
*/
|
||||
@@ -606,8 +631,15 @@ class TaskDial extends Task {
|
||||
if (this.parentDtmfCollector) this._installDtmfDetection(cs, cs.dlg);
|
||||
if (this.childDtmfCollector) this._installDtmfDetection(cs, this.dlg);
|
||||
|
||||
if (this.transcribeTask) this.transcribeTask.exec(cs, this.epOther);
|
||||
if (this.listenTask) this.listenTask.exec(cs, this.epOther);
|
||||
if (this.transcribeTask) this.transcribeTask.exec(cs, {ep2: this.epOther, ep:this.ep});
|
||||
if (this.listenTask) this.listenTask.exec(cs, {ep: this.epOther});
|
||||
if (this.startAmd) {
|
||||
try {
|
||||
this.startAmd(cs, this.ep, this, this.data.amd);
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Dial:_selectSingleDial - Error calling startAmd');
|
||||
}
|
||||
}
|
||||
|
||||
/* if we can release the media back to the SBC, do so now */
|
||||
if (this.canReleaseMedia) this._releaseMedia(cs, sd);
|
||||
@@ -654,6 +686,12 @@ class TaskDial extends Task {
|
||||
this.logger.info({sdp}, 'Dial:handleReinviteAfterMediaReleased - sent reinvite to B leg');
|
||||
res.send(200, {body: sdp});
|
||||
}
|
||||
|
||||
_onAmdEvent(cs, evt) {
|
||||
this.logger.info({evt}, 'Dial:_onAmdEvent');
|
||||
const {actionHook} = this.data.amd;
|
||||
this.performHook(cs, actionHook, evt);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskDial;
|
||||
|
||||
@@ -64,7 +64,7 @@ class Dialogflow extends Task {
|
||||
|
||||
get name() { return TaskName.Dialogflow; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
|
||||
try {
|
||||
|
||||
@@ -12,7 +12,7 @@ class TaskDtmf extends Task {
|
||||
|
||||
get name() { return TaskName.Dtmf; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
this.ep = ep;
|
||||
try {
|
||||
|
||||
@@ -37,7 +37,7 @@ class TaskEnqueue extends Task {
|
||||
|
||||
get name() { return TaskName.Enqueue; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
const dlg = cs.dlg;
|
||||
this.queueName = `queue:${cs.accountSid}:${this.queueName}`;
|
||||
@@ -349,7 +349,8 @@ class TaskEnqueue extends Task {
|
||||
ep: cs.ep,
|
||||
callInfo: cs.callInfo,
|
||||
accountInfo: cs.accountInfo,
|
||||
tasks: tasksToRun
|
||||
tasks: tasksToRun,
|
||||
rootSpan: cs.rootSpan
|
||||
});
|
||||
await this._playSession.exec();
|
||||
this._playSession = null;
|
||||
|
||||
@@ -23,11 +23,11 @@ class TaskGather extends Task {
|
||||
].forEach((k) => this[k] = this.data[k]);
|
||||
|
||||
/* when collecting dtmf, bargein on dtmf is true unless explicitly set to false */
|
||||
if (this.dtmfBargein !== false && this.input.includes('digits')) this.dtmfBargein = true;
|
||||
if (this.dtmfBargein !== false && this.input.includes('digits')) this.dtmfBargein = true;
|
||||
|
||||
/* timeout of zero means no timeout */
|
||||
this.timeout = this.timeout === 0 ? 0 : (this.timeout || 15) * 1000;
|
||||
this.interim = this.partialResultHook || this.bargein;
|
||||
this.interim = !!this.partialResultHook || this.bargein;
|
||||
this.listenDuringPrompt = this.data.listenDuringPrompt === false ? false : true;
|
||||
this.minBargeinWordCount = this.data.minBargeinWordCount || 0;
|
||||
if (this.data.recognizer) {
|
||||
@@ -49,6 +49,11 @@ class TaskGather extends Task {
|
||||
this.naicsCode = recognizer.naicsCode || 0;
|
||||
this.altLanguages = recognizer.altLanguages || [];
|
||||
|
||||
/* continuous ASR (i.e. compile transcripts until a special timeout or dtmf key) */
|
||||
this.asrTimeout = typeof recognizer.asrTimeout === 'number' ? recognizer.asrTimeout * 1000 : 0;
|
||||
if (this.asrTimeout > 0) this.asrDtmfTerminationDigit = recognizer.asrDtmfTerminationDigit;
|
||||
this.isContinuousAsr = this.asrTimeout > 0;
|
||||
|
||||
/* vad: if provided, we dont connect to recognizer until voice activity is detected */
|
||||
const {enable, voiceMs = 0, mode = -1} = recognizer.vad || {};
|
||||
this.vad = {enable, voiceMs, mode};
|
||||
@@ -65,6 +70,10 @@ class TaskGather extends Task {
|
||||
this.initialSpeechTimeoutMs = recognizer.initialSpeechTimeoutMs || 0;
|
||||
this.azureServiceEndpoint = recognizer.azureServiceEndpoint;
|
||||
}
|
||||
else {
|
||||
this.hints = [];
|
||||
this.altLanguages = [];
|
||||
}
|
||||
|
||||
this.digitBuffer = '';
|
||||
this._earlyMedia = this.data.earlyMedia === true;
|
||||
@@ -77,6 +86,9 @@ class TaskGather extends Task {
|
||||
}
|
||||
if (!this.sayTask && !this.playTask) this.listenDuringPrompt = false;
|
||||
|
||||
/* buffer speech for continueous asr */
|
||||
this._bufferedTranscripts = [];
|
||||
|
||||
this.parentTask = parentTask;
|
||||
}
|
||||
|
||||
@@ -104,11 +116,20 @@ class TaskGather extends Task {
|
||||
return s;
|
||||
}
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
this.logger.debug('Gather:exec');
|
||||
await super.exec(cs);
|
||||
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
|
||||
|
||||
if (!this.isContinuousAsr && cs.isContinuousAsr) {
|
||||
this.isContinuousAsr = true;
|
||||
this.asrTimeout = cs.asrTimeout * 1000;
|
||||
this.asrDtmfTerminationDigit = cs.asrDtmfTerminationDigit;
|
||||
this.logger.debug({
|
||||
asrTimeout: this.asrTimeout,
|
||||
asrDtmfTerminationDigit: this.asrDtmfTerminationDigit
|
||||
}, 'Gather:exec - enabling continuous ASR since it is turned on for the session');
|
||||
}
|
||||
this.ep = ep;
|
||||
if ('default' === this.vendor || !this.vendor) this.vendor = cs.speechRecognizerVendor;
|
||||
if ('default' === this.language || !this.language) this.language = cs.speechRecognizerLanguage;
|
||||
@@ -127,6 +148,7 @@ class TaskGather extends Task {
|
||||
|
||||
const startListening = (cs, ep) => {
|
||||
this._startTimer();
|
||||
if (this.isContinuousAsr && 0 === this.timeout) this._startAsrTimer();
|
||||
if (this.input.includes('speech') && !this.listenDuringPrompt) {
|
||||
this._initSpeech(cs, ep)
|
||||
.then(() => {
|
||||
@@ -142,7 +164,7 @@ class TaskGather extends Task {
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.sayTask.summary}`);
|
||||
this.sayTask.span = span;
|
||||
this.sayTask.ctx = ctx;
|
||||
this.sayTask.exec(cs, ep); // kicked off, _not_ waiting for it to complete
|
||||
this.sayTask.exec(cs, {ep}); // kicked off, _not_ waiting for it to complete
|
||||
this.sayTask.on('playDone', (err) => {
|
||||
span.end();
|
||||
if (err) this.logger.error({err}, 'Gather:exec Error playing tts');
|
||||
@@ -154,7 +176,7 @@ class TaskGather extends Task {
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.playTask.summary}`);
|
||||
this.playTask.span = span;
|
||||
this.playTask.ctx = ctx;
|
||||
this.playTask.exec(cs, ep); // kicked off, _not_ waiting for it to complete
|
||||
this.playTask.exec(cs, {ep}); // kicked off, _not_ waiting for it to complete
|
||||
this.playTask.on('playDone', (err) => {
|
||||
span.end();
|
||||
if (err) this.logger.error({err}, 'Gather:exec Error playing url');
|
||||
@@ -171,7 +193,7 @@ class TaskGather extends Task {
|
||||
.catch(() => {/*already logged error */});
|
||||
}
|
||||
|
||||
if (this.input.includes('digits') || this.dtmfBargein) {
|
||||
if (this.input.includes('digits') || this.dtmfBargein || this.asrDtmfTerminationDigit) {
|
||||
ep.on('dtmf', this._onDtmf.bind(this, cs, ep));
|
||||
}
|
||||
|
||||
@@ -209,12 +231,15 @@ class TaskGather extends Task {
|
||||
this.logger.debug(evt, 'TaskGather:_onDtmf');
|
||||
clearTimeout(this.interDigitTimer);
|
||||
let resolved = false;
|
||||
if (this.dtmfBargein) this._killAudio(cs);
|
||||
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 {
|
||||
else if (this.input.includes('digits')) {
|
||||
this.digitBuffer += evt.dtmf;
|
||||
const len = this.digitBuffer.length;
|
||||
if (len === this.numDigits || len === this.maxDigits) {
|
||||
@@ -222,6 +247,13 @@ class TaskGather extends Task {
|
||||
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;
|
||||
@@ -241,6 +273,7 @@ class TaskGather extends Task {
|
||||
}
|
||||
|
||||
if ('google' === this.vendor) {
|
||||
this.bugname = 'google_trancribe';
|
||||
if (this.sttCredentials) opts.GOOGLE_APPLICATION_CREDENTIALS = JSON.stringify(this.sttCredentials.credentials);
|
||||
[
|
||||
['enhancedModel', 'GOOGLE_SPEECH_USE_ENHANCED'],
|
||||
@@ -276,6 +309,7 @@ class TaskGather extends Task {
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep));
|
||||
}
|
||||
else if (['aws', 'polly'].includes(this.vendor)) {
|
||||
this.bugname = 'aws_trancribe';
|
||||
if (this.vocabularyName) opts.AWS_VOCABULARY_NAME = this.vocabularyName;
|
||||
if (this.vocabularyFilterName) {
|
||||
opts.AWS_VOCABULARY_NAME = this.vocabularyFilterName;
|
||||
@@ -292,6 +326,7 @@ class TaskGather extends Task {
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep));
|
||||
}
|
||||
else if ('microsoft' === this.vendor) {
|
||||
this.bugname = 'azure_trancribe';
|
||||
if (this.sttCredentials) {
|
||||
Object.assign(opts, {
|
||||
'AZURE_SUBSCRIPTION_KEY': this.sttCredentials.api_key,
|
||||
@@ -323,12 +358,14 @@ class TaskGather extends Task {
|
||||
this.logger.debug({
|
||||
vendor: this.vendor,
|
||||
locale: this.language,
|
||||
interim: this.interim
|
||||
interim: this.interim,
|
||||
bugname: this.bugname
|
||||
}, 'Gather:_startTranscribing');
|
||||
ep.startTranscription({
|
||||
vendor: this.vendor,
|
||||
locale: this.language,
|
||||
interim: this.interim,
|
||||
bugname: this.bugname,
|
||||
}).catch((err) => {
|
||||
const {writeAlerts, AlertType} = this.cs.srf.locals;
|
||||
this.logger.error(err, 'TaskGather:_startTranscribing error');
|
||||
@@ -343,13 +380,11 @@ class TaskGather extends Task {
|
||||
|
||||
_startTimer() {
|
||||
if (0 === this.timeout) return;
|
||||
if (this._timeoutTimer) {
|
||||
clearTimeout(this._timeoutTimer);
|
||||
this._timeoutTimer = null;
|
||||
}
|
||||
assert(!this._timeoutTimer);
|
||||
this.logger.debug(`Gather:_startTimer: timeout ${this.timeout}`);
|
||||
this._timeoutTimer = setTimeout(() => this._resolve('timeout'), this.timeout);
|
||||
this._clearTimer();
|
||||
this._timeoutTimer = setTimeout(() => {
|
||||
if (this.isContinuousAsr) this._startAsrTimer();
|
||||
else this._resolve(this.digitBuffer.length >= this.minDigits ? 'dtmf-num-digits' : 'timeout');
|
||||
}, this.timeout);
|
||||
}
|
||||
|
||||
_clearTimer() {
|
||||
@@ -359,6 +394,35 @@ class TaskGather extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
_startAsrTimer() {
|
||||
assert(this.isContinuousAsr);
|
||||
this._clearAsrTimer();
|
||||
this._asrTimer = setTimeout(() => {
|
||||
this.logger.debug('_startAsrTimer - asr timer went off');
|
||||
this._resolve(this._bufferedTranscripts.length > 0 ? 'speech' : 'timeout');
|
||||
}, this.asrTimeout);
|
||||
this.logger.debug(`_startAsrTimer: set for ${this.asrTimeout}ms`);
|
||||
}
|
||||
|
||||
_clearAsrTimer() {
|
||||
if (this._asrTimer) clearTimeout(this._asrTimer);
|
||||
this._asrTimer = null;
|
||||
}
|
||||
|
||||
_startFinalAsrTimer() {
|
||||
this._clearFinalAsrTimer();
|
||||
this._finalAsrTimer = setTimeout(() => {
|
||||
this.logger.debug('_startFinalAsrTimer - final asr timer went off');
|
||||
this._resolve(this._bufferedTranscripts.length > 0 ? 'speech' : 'timeout');
|
||||
}, 1000);
|
||||
this.logger.debug('_startFinalAsrTimer: set for 1 second');
|
||||
}
|
||||
|
||||
_clearFinalAsrTimer() {
|
||||
if (this._finalAsrTimer) clearTimeout(this._finalAsrTimer);
|
||||
this._finalAsrTimer = null;
|
||||
}
|
||||
|
||||
_killAudio(cs) {
|
||||
if (!this.sayTask && !this.playTask && this.bargein) {
|
||||
if (this.ep?.connected && !this.playComplete) {
|
||||
@@ -381,7 +445,11 @@ class TaskGather extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
_onTranscription(cs, ep, evt) {
|
||||
_onTranscription(cs, ep, evt, fsEvent) {
|
||||
// make sure this is not a transcript from answering machine detection
|
||||
const bugname = fsEvent.getHeader('media-bugname');
|
||||
if (bugname && this.bugname !== bugname) return;
|
||||
|
||||
if ('aws' === this.vendor && Array.isArray(evt) && evt.length > 0) evt = evt[0];
|
||||
if ('microsoft' === this.vendor) {
|
||||
const final = evt.RecognitionStatus === 'Success';
|
||||
@@ -417,7 +485,19 @@ class TaskGather extends Task {
|
||||
this.logger.info({evt}, 'TaskGather:_onTranscription - got empty transcript, listen again');
|
||||
return this._startTranscribing(ep);
|
||||
}
|
||||
this._resolve('speech', evt);
|
||||
if (this.isContinuousAsr) {
|
||||
/* append the transcript and start listening again for asrTimeout */
|
||||
this.logger.info({evt}, 'TaskGather:_onTranscription - got transcript during continous asr');
|
||||
this._bufferedTranscripts.push(evt);
|
||||
this._clearTimer();
|
||||
if (this._finalAsrTimer) {
|
||||
this._clearFinalAsrTimer();
|
||||
return this._resolve(this._bufferedTranscripts.length > 0 ? 'speech' : 'timeout');
|
||||
}
|
||||
this._startAsrTimer();
|
||||
return this._startTranscribing(ep);
|
||||
}
|
||||
else this._resolve('speech', evt);
|
||||
}
|
||||
else {
|
||||
/* google has a measure of stability:
|
||||
@@ -437,7 +517,7 @@ class TaskGather extends Task {
|
||||
if (this.partialResultHook) {
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
this.cs.requestor.request(this.partialResultHook, Object.assign({speech: evt},
|
||||
this.cs.requestor.request('verb:hook', this.partialResultHook, Object.assign({speech: evt},
|
||||
this.cs.callInfo, httpHeaders));
|
||||
}
|
||||
}
|
||||
@@ -474,6 +554,15 @@ class TaskGather extends Task {
|
||||
|
||||
this.resolved = true;
|
||||
clearTimeout(this.interDigitTimer);
|
||||
this._clearTimer();
|
||||
|
||||
if (this.isContinuousAsr && reason.startsWith('speech')) {
|
||||
evt = {
|
||||
is_final: true,
|
||||
transcripts: this._bufferedTranscripts
|
||||
};
|
||||
this.logger.debug({evt}, 'TaskGather:resolve continuous asr');
|
||||
}
|
||||
|
||||
this.span.setAttributes({'stt.resolve': reason, 'stt.result': JSON.stringify(evt)});
|
||||
if (this.ep && this.ep.connected) {
|
||||
@@ -481,8 +570,6 @@ class TaskGather extends Task {
|
||||
.catch((err) => this.logger.error({err}, 'Error stopping transcription'));
|
||||
}
|
||||
|
||||
this._clearTimer();
|
||||
|
||||
if (this.callSession && this.callSession.callGone) {
|
||||
this.logger.debug('TaskGather:_resolve - call is gone, not invoking web callback');
|
||||
this.notifyTaskDone();
|
||||
|
||||
@@ -14,7 +14,7 @@ class TaskHangup extends Task {
|
||||
/**
|
||||
* Hangup the call
|
||||
*/
|
||||
async exec(cs, dlg) {
|
||||
async exec(cs, {dlg}) {
|
||||
await super.exec(cs);
|
||||
try {
|
||||
await dlg.destroy({headers: this.headers});
|
||||
|
||||
@@ -8,7 +8,7 @@ class TaskLeave extends Task {
|
||||
|
||||
get name() { return TaskName.Leave; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
await this.awaitTaskDone();
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ class Lex extends Task {
|
||||
|
||||
get name() { return TaskName.Lex; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
|
||||
try {
|
||||
|
||||
@@ -22,15 +22,14 @@ class TaskListen extends Task {
|
||||
this.results = {};
|
||||
|
||||
if (this.transcribe) this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe}, this);
|
||||
|
||||
this._dtmfHandler = this._onDtmf.bind(this);
|
||||
}
|
||||
|
||||
get name() { return TaskName.Listen; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
this.ep = ep;
|
||||
this._dtmfHandler = this._onDtmf.bind(this, ep);
|
||||
|
||||
try {
|
||||
this.hook = this.normalizeUrl(this.url, 'GET', this.wsAuth);
|
||||
@@ -41,7 +40,7 @@ class TaskListen extends Task {
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.transcribeTask.summary}`);
|
||||
this.transcribeTask.span = span;
|
||||
this.transcribeTask.ctx = ctx;
|
||||
this.transcribeTask.exec(cs, ep)
|
||||
this.transcribeTask.exec(cs, {ep})
|
||||
.then((result) => span.end())
|
||||
.catch((err) => span.end());
|
||||
}
|
||||
@@ -61,14 +60,21 @@ class TaskListen extends Task {
|
||||
this._clearTimer();
|
||||
if (this.ep && this.ep.connected) {
|
||||
this.logger.debug('TaskListen:kill closing websocket');
|
||||
await this.ep.forkAudioStop()
|
||||
.catch((err) => this.logger.info(err, 'TaskListen:kill'));
|
||||
try {
|
||||
await this.ep.forkAudioStop();
|
||||
this.logger.debug('TaskListen:kill successfully closed websocket');
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'TaskListen:kill');
|
||||
}
|
||||
}
|
||||
if (this.recordStartTime) {
|
||||
const duration = moment().diff(this.recordStartTime, 'seconds');
|
||||
this.results.dialCallDuration = duration;
|
||||
}
|
||||
if (this.transcribeTask) await this.transcribeTask.kill(cs);
|
||||
if (this.transcribeTask) {
|
||||
await this.transcribeTask.kill(cs);
|
||||
this.transcribeTask = null;
|
||||
}
|
||||
this.ep && this._removeListeners(this.ep);
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
@@ -148,7 +154,13 @@ class TaskListen extends Task {
|
||||
|
||||
}
|
||||
|
||||
_onDtmf(evt) {
|
||||
_onDtmf(ep, evt) {
|
||||
this.logger.debug({evt}, `TaskListen:_onDtmf received dtmf ${evt.dtmf}`);
|
||||
if (this.passDtmf && this.ep?.connected) {
|
||||
const obj = {event: 'dtmf', dtmf: evt.dtmf, duration: evt.duration};
|
||||
this.ep.forkAudioSendText(obj)
|
||||
.catch((err) => this.logger.info({err}, 'TaskListen:_onDtmf error sending dtmf'));
|
||||
}
|
||||
if (evt.dtmf === this.finishOnKey) {
|
||||
this.logger.info(`TaskListen:_onDtmf terminating task due to dtmf ${evt.dtmf}`);
|
||||
this.results.digits = evt.dtmf;
|
||||
@@ -207,7 +219,7 @@ class TaskListen extends Task {
|
||||
this.logger.debug('Listen:whisper tasks starting');
|
||||
while (tasks.length && !cs.callGone) {
|
||||
const task = tasks.shift();
|
||||
await task.exec(cs, this.ep);
|
||||
await task.exec(cs, {ep: this.ep});
|
||||
}
|
||||
this.logger.debug('Listen:whisper tasks complete');
|
||||
} catch (err) {
|
||||
|
||||
@@ -17,6 +17,9 @@ function makeTask(logger, obj, parent) {
|
||||
case TaskName.SipDecline:
|
||||
const TaskSipDecline = require('./sip_decline');
|
||||
return new TaskSipDecline(logger, data, parent);
|
||||
case TaskName.SipRequest:
|
||||
const TaskSipRequest = require('./sip_request');
|
||||
return new TaskSipRequest(logger, data, parent);
|
||||
case TaskName.SipRefer:
|
||||
const TaskSipRefer = require('./sip_refer');
|
||||
return new TaskSipRefer(logger, data, parent);
|
||||
|
||||
@@ -10,7 +10,7 @@ class TaskPause extends Task {
|
||||
|
||||
get name() { return TaskName.Pause; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs) {
|
||||
await super.exec(cs);
|
||||
this.timer = setTimeout(this.notifyTaskDone.bind(this), this.length * 1000);
|
||||
await this.awaitTaskDone();
|
||||
|
||||
@@ -17,7 +17,7 @@ class TaskPlay extends Task {
|
||||
return `${this.name}:{url=${this.url}}`;
|
||||
}
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
this.ep = ep;
|
||||
try {
|
||||
|
||||
@@ -20,7 +20,7 @@ class Rasa extends Task {
|
||||
return this.reportedFinalAction || this.isReplacingApplication;
|
||||
}
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
|
||||
this.ep = ep;
|
||||
@@ -34,7 +34,7 @@ class Rasa extends Task {
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.gatherTask.summary}`);
|
||||
this.gatherTask.span = span;
|
||||
this.gatherTask.ctx = ctx;
|
||||
this.gatherTask.exec(cs, ep, this)
|
||||
this.gatherTask.exec(cs, {ep})
|
||||
.then(() => span.end())
|
||||
.catch((err) => {
|
||||
span.end();
|
||||
@@ -128,7 +128,7 @@ class Rasa extends Task {
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.gatherTask.summary}`);
|
||||
this.gatherTask.span = span;
|
||||
this.gatherTask.ctx = ctx;
|
||||
this.gatherTask.exec(cs, ep, this)
|
||||
this.gatherTask.exec(cs, {ep})
|
||||
.then(() => span.end())
|
||||
.catch((err) => {
|
||||
span.end();
|
||||
|
||||
@@ -50,7 +50,21 @@ class TaskRestDial extends Task {
|
||||
try {
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
const tasks = await cs.requestor.request('session:new', this.call_hook, cs.callInfo, httpHeaders);
|
||||
const params = {
|
||||
...cs.callInfo,
|
||||
defaults: {
|
||||
synthesizer: {
|
||||
vendor: cs.speechSynthesisVendor,
|
||||
language: cs.speechSynthesisLanguage,
|
||||
voice: cs.speechSynthesisVoice
|
||||
},
|
||||
recognizer: {
|
||||
vendor: cs.speechRecognizerVendor,
|
||||
language: cs.speechRecognizerLanguage
|
||||
}
|
||||
}
|
||||
};
|
||||
const tasks = await cs.requestor.request('session:new', this.call_hook, params, httpHeaders);
|
||||
if (tasks && Array.isArray(tasks)) {
|
||||
this.logger.debug({tasks: tasks}, `TaskRestDial: replacing application with ${tasks.length} tasks`);
|
||||
cs.replaceApplication(normalizeJambones(this.logger, tasks).map((tdata) => makeTask(this.logger, tdata)));
|
||||
|
||||
@@ -23,7 +23,7 @@ class TaskSayLegacy extends Task {
|
||||
|
||||
get name() { return TaskName.SayLegacy; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
super.exec(cs);
|
||||
this.ep = ep;
|
||||
try {
|
||||
|
||||
@@ -22,7 +22,7 @@ class TaskSay extends Task {
|
||||
return `${this.name}{${this.text[0]}}`;
|
||||
}
|
||||
|
||||
async exec(cs, ep) {
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
|
||||
const {srf} = cs;
|
||||
|
||||
49
lib/tasks/sip_request.js
Normal file
49
lib/tasks/sip_request.js
Normal file
@@ -0,0 +1,49 @@
|
||||
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;
|
||||
@@ -9,6 +9,17 @@
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"sip:request": {
|
||||
"properties": {
|
||||
"method": "string",
|
||||
"body": "string",
|
||||
"headers": "object",
|
||||
"actionHook": "object|string"
|
||||
},
|
||||
"required": [
|
||||
"method"
|
||||
]
|
||||
},
|
||||
"sip:refer": {
|
||||
"properties": {
|
||||
"referTo": "string",
|
||||
@@ -25,7 +36,9 @@
|
||||
"properties": {
|
||||
"synthesizer": "#synthesizer",
|
||||
"recognizer": "#recognizer",
|
||||
"bargeIn": "#bargeIn"
|
||||
"bargeIn": "#bargeIn",
|
||||
"record": "#recordOptions",
|
||||
"amd": "#amd"
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
@@ -160,7 +173,8 @@
|
||||
"timeLimit": "number",
|
||||
"timeout": "number",
|
||||
"proxy": "string",
|
||||
"transcribe": "#transcribe"
|
||||
"transcribe": "#transcribe",
|
||||
"amd": "#amd"
|
||||
},
|
||||
"required": [
|
||||
"target"
|
||||
@@ -296,6 +310,19 @@
|
||||
"path"
|
||||
]
|
||||
},
|
||||
"recordOptions": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["startCallRecording", "stopCallRecording", "pauseCallRecording", "resumeCallRecording"]
|
||||
},
|
||||
"recordingID": "string",
|
||||
"siprecServerURL": "string"
|
||||
},
|
||||
"required": [
|
||||
"action"
|
||||
]
|
||||
},
|
||||
"redirect": {
|
||||
"properties": {
|
||||
"actionHook": "object|string"
|
||||
@@ -466,7 +493,9 @@
|
||||
},
|
||||
"requestSnr": "boolean",
|
||||
"initialSpeechTimeoutMs": "number",
|
||||
"azureServiceEndpoint": "string"
|
||||
"azureServiceEndpoint": "string",
|
||||
"asrDtmfTerminationDigit": "string",
|
||||
"asrTimeout": "number"
|
||||
},
|
||||
"required": [
|
||||
"vendor"
|
||||
@@ -490,5 +519,24 @@
|
||||
"required": [
|
||||
"enable"
|
||||
]
|
||||
},
|
||||
"amd": {
|
||||
"properties": {
|
||||
"actionHook": "object|string",
|
||||
"thresholdWordCount": "number",
|
||||
"timers": "#amdTimers",
|
||||
"recognizer": "#recognizer"
|
||||
},
|
||||
"required": [
|
||||
"actionHook"
|
||||
]
|
||||
},
|
||||
"amdTimers": {
|
||||
"properties": {
|
||||
"noSpeechTimeoutMs": "number",
|
||||
"decisionTimeoutMs": "number",
|
||||
"toneTimeoutMs": "number",
|
||||
"greetingCompletionTimeoutMs": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ class Task extends Emitter {
|
||||
|
||||
async performAction(results, expectResponse = true) {
|
||||
if (this.actionHook) {
|
||||
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 span = this.startSpan('verb:hook', {'hook.url': this.actionHook});
|
||||
const b3 = this.getTracingPropagation('b3', span);
|
||||
const httpHeaders = b3 && {b3};
|
||||
@@ -171,12 +171,13 @@ class Task extends Emitter {
|
||||
}
|
||||
|
||||
async performHook(cs, hook, results) {
|
||||
const params = results ? Object.assign(cs.callInfo.toJSON(), results) : cs.callInfo.toJSON();
|
||||
const span = this.startSpan('verb:hook', {'hook.url': hook});
|
||||
const b3 = this.getTracingPropagation('b3', span);
|
||||
const httpHeaders = b3 && {b3};
|
||||
span.setAttributes({'http.body': JSON.stringify(results)});
|
||||
span.setAttributes({'http.body': JSON.stringify(params)});
|
||||
try {
|
||||
const json = await cs.requestor.request('verb:hook', hook, results, httpHeaders);
|
||||
const json = await cs.requestor.request('verb:hook', hook, params, httpHeaders);
|
||||
span.setAttributes({'http.statusCode': 200});
|
||||
span.end();
|
||||
if (json && Array.isArray(json)) {
|
||||
|
||||
@@ -58,11 +58,12 @@ class TaskTranscribe extends Task {
|
||||
|
||||
get name() { return TaskName.Transcribe; }
|
||||
|
||||
async exec(cs, ep, parentTask) {
|
||||
async exec(cs, {ep, ep2}) {
|
||||
super.exec(cs);
|
||||
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
|
||||
|
||||
this.ep = ep;
|
||||
this.ep2 = ep2;
|
||||
if ('default' === this.vendor || !this.vendor) this.vendor = cs.speechRecognizerVendor;
|
||||
if ('default' === this.language || !this.language) this.language = cs.speechRecognizerLanguage;
|
||||
this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt');
|
||||
@@ -78,7 +79,11 @@ class TaskTranscribe extends Task {
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for no stt'));
|
||||
throw new Error('no provisioned speech credentials for TTS');
|
||||
}
|
||||
await this._startTranscribing(cs, ep);
|
||||
await this._startTranscribing(cs, ep, 1);
|
||||
if (this.separateRecognitionPerChannel && ep2) {
|
||||
await this._startTranscribing(cs, ep2, 2);
|
||||
}
|
||||
|
||||
updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid)
|
||||
.catch(() => {/*already logged error */});
|
||||
|
||||
@@ -99,18 +104,25 @@ class TaskTranscribe extends Task {
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
if (this.ep.connected) {
|
||||
let stopTranscription = false;
|
||||
if (this.ep?.connected) {
|
||||
stopTranscription = true;
|
||||
this.ep.stopTranscription({vendor: this.vendor})
|
||||
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
||||
|
||||
// hangup after 1 sec if we don't get a final transcription
|
||||
this._timer = setTimeout(() => this.notifyTaskDone(), 1000);
|
||||
}
|
||||
if (this.separateRecognitionPerChannel && this.ep2 && this.ep2.connected) {
|
||||
stopTranscription = true;
|
||||
this.ep2.stopTranscription({vendor: this.vendor})
|
||||
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
||||
}
|
||||
// hangup after 1 sec if we don't get a final transcription
|
||||
if (stopTranscription) this._timer = setTimeout(() => this.notifyTaskDone(), 1500);
|
||||
else this.notifyTaskDone();
|
||||
|
||||
await this.awaitTaskDone();
|
||||
}
|
||||
|
||||
async _startTranscribing(cs, ep) {
|
||||
async _startTranscribing(cs, ep, channel) {
|
||||
const opts = {};
|
||||
|
||||
if (this.vad.enable) {
|
||||
@@ -119,22 +131,25 @@ class TaskTranscribe extends Task {
|
||||
if (this.vad.mode >= 0 && this.vad.mode <= 3) opts.RECOGNIZER_VAD_MODE = this.vad.mode;
|
||||
}
|
||||
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.MaxDurationExceeded,
|
||||
this._onMaxDurationExceeded.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, cs, ep));
|
||||
this._onMaxDurationExceeded.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.MaxDurationExceeded,
|
||||
this._onMaxDurationExceeded.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected, this._onNoAudio.bind(this, cs, ep));
|
||||
this._onMaxDurationExceeded.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected, this._onNoAudio.bind(this, cs, ep, channel));
|
||||
|
||||
if (this.vendor === 'google') {
|
||||
this.bugname = 'google_trancribe';
|
||||
if (this.sttCredentials) opts.GOOGLE_APPLICATION_CREDENTIALS = JSON.stringify(this.sttCredentials.credentials);
|
||||
[
|
||||
['enhancedModel', 'GOOGLE_SPEECH_USE_ENHANCED'],
|
||||
['separateRecognitionPerChannel', 'GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL'],
|
||||
//['separateRecognitionPerChannel', 'GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL'],
|
||||
['profanityFilter', 'GOOGLE_SPEECH_PROFANITY_FILTER'],
|
||||
['punctuation', 'GOOGLE_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION'],
|
||||
['words', 'GOOGLE_SPEECH_ENABLE_WORD_TIME_OFFSETS'],
|
||||
@@ -166,6 +181,7 @@ class TaskTranscribe extends Task {
|
||||
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with google'));
|
||||
}
|
||||
else if (this.vendor === 'aws') {
|
||||
this.bugname = 'aws_trancribe';
|
||||
[
|
||||
['diarization', 'AWS_SHOW_SPEAKER_LABEL'],
|
||||
['identifyChannels', 'AWS_ENABLE_CHANNEL_IDENTIFICATION']
|
||||
@@ -197,6 +213,7 @@ class TaskTranscribe extends Task {
|
||||
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with aws'));
|
||||
}
|
||||
else if (this.vendor === 'microsoft') {
|
||||
this.bugname = 'azure_trancribe';
|
||||
Object.assign(opts, {
|
||||
'AZURE_SUBSCRIPTION_KEY': this.sttCredentials.api_key,
|
||||
'AZURE_REGION': this.sttCredentials.region
|
||||
@@ -222,12 +239,17 @@ class TaskTranscribe extends Task {
|
||||
vendor: this.vendor,
|
||||
interim: this.interim ? true : false,
|
||||
locale: this.language,
|
||||
channels: this.separateRecognitionPerChannel ? 2 : 1
|
||||
channels: /*this.separateRecognitionPerChannel ? 2 : */ 1,
|
||||
bugname: this.bugname
|
||||
});
|
||||
}
|
||||
|
||||
_onTranscription(cs, ep, evt) {
|
||||
this.logger.debug({evt}, 'TaskTranscribe:_onTranscription');
|
||||
_onTranscription(cs, ep, channel, evt, fsEvent) {
|
||||
// make sure this is not a transcript from answering machine detection
|
||||
const bugname = fsEvent.getHeader('media-bugname');
|
||||
if (bugname && this.bugname !== bugname) return;
|
||||
|
||||
this.logger.debug({evt, channel}, 'TaskTranscribe:_onTranscription');
|
||||
if ('aws' === this.vendor && Array.isArray(evt) && evt.length > 0) evt = evt[0];
|
||||
if ('microsoft' === this.vendor) {
|
||||
const nbest = evt.NBest;
|
||||
@@ -240,12 +262,13 @@ class TaskTranscribe extends Task {
|
||||
}) :
|
||||
[
|
||||
{
|
||||
transcript: evt.DisplayText
|
||||
transcript: evt.DisplayText || evt.Text
|
||||
}
|
||||
];
|
||||
|
||||
const newEvent = {
|
||||
is_final: evt.RecognitionStatus === 'Success',
|
||||
channel,
|
||||
language_code,
|
||||
alternatives
|
||||
};
|
||||
@@ -257,6 +280,8 @@ class TaskTranscribe extends Task {
|
||||
return this._transcribe(ep);
|
||||
}
|
||||
|
||||
evt.channel_tag = channel;
|
||||
|
||||
if (this.transcriptionHook) {
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
@@ -274,13 +299,13 @@ class TaskTranscribe extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
_onNoAudio(cs, ep) {
|
||||
this.logger.debug('TaskTranscribe:_onNoAudio restarting transcription');
|
||||
_onNoAudio(cs, ep, channel) {
|
||||
this.logger.debug(`TaskTranscribe:_onNoAudio restarting transcription on channel ${channel}`);
|
||||
this._transcribe(ep);
|
||||
}
|
||||
|
||||
_onMaxDurationExceeded(cs, ep) {
|
||||
this.logger.debug('TaskTranscribe:_onMaxDurationExceeded restarting transcription');
|
||||
_onMaxDurationExceeded(cs, ep, channel) {
|
||||
this.logger.debug(`TaskTranscribe:_onMaxDurationExceeded restarting transcription on channel ${channel}`);
|
||||
this._transcribe(ep);
|
||||
}
|
||||
|
||||
|
||||
324
lib/utils/amd-utils.js
Normal file
324
lib/utils/amd-utils.js
Normal file
@@ -0,0 +1,324 @@
|
||||
const Emitter = require('events');
|
||||
const {readFile} = require('fs');
|
||||
const {
|
||||
GoogleTranscriptionEvents,
|
||||
AwsTranscriptionEvents,
|
||||
AzureTranscriptionEvents,
|
||||
AmdEvents,
|
||||
AvmdEvents
|
||||
} = require('./constants');
|
||||
const bugname = 'amd_bug';
|
||||
const {VMD_HINTS_FILE} = process.env;
|
||||
let voicemailHints = [];
|
||||
|
||||
const updateHints = async(file, callback) => {
|
||||
readFile(file, 'utf8', (err, data) => {
|
||||
if (err) return callback(err);
|
||||
try {
|
||||
callback(null, JSON.parse(data));
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (VMD_HINTS_FILE) {
|
||||
updateHints(VMD_HINTS_FILE, (err, hints) => {
|
||||
if (err) { console.error(err); }
|
||||
voicemailHints = hints;
|
||||
|
||||
/* if successful, update the hints every hour */
|
||||
setInterval(() => {
|
||||
updateHints(VMD_HINTS_FILE, (err, hints) => {
|
||||
if (err) { console.error(err); }
|
||||
voicemailHints = hints;
|
||||
});
|
||||
}, 60000);
|
||||
});
|
||||
}
|
||||
|
||||
class Amd extends Emitter {
|
||||
constructor(logger, cs, opts) {
|
||||
super();
|
||||
this.logger = logger;
|
||||
this.vendor = opts.recognizer?.vendor || cs.speechRecognizerVendor;
|
||||
if ('default' === this.vendor) this.vendor = cs.speechRecognizerVendor;
|
||||
|
||||
this.language = opts.recognizer?.language || cs.speechRecognizerLanguage;
|
||||
if ('default' === this.language) this.language = cs.speechRecognizerLanguage;
|
||||
|
||||
this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt');
|
||||
|
||||
if (!this.sttCredentials) throw new Error(`No speech credentials found for vendor ${this.vendor}`);
|
||||
|
||||
this.thresholdWordCount = opts.thresholdWordCount || 9;
|
||||
const {normalizeTranscription} = require('./transcription-utils')(logger);
|
||||
this.normalizeTranscription = normalizeTranscription;
|
||||
|
||||
const {
|
||||
noSpeechTimeoutMs = 5000,
|
||||
decisionTimeoutMs = 15000,
|
||||
toneTimeoutMs = 20000,
|
||||
greetingCompletionTimeoutMs = 2000
|
||||
} = opts.timers || {};
|
||||
this.noSpeechTimeoutMs = noSpeechTimeoutMs;
|
||||
this.decisionTimeoutMs = decisionTimeoutMs;
|
||||
this.toneTimeoutMs = toneTimeoutMs;
|
||||
this.greetingCompletionTimeoutMs = greetingCompletionTimeoutMs;
|
||||
|
||||
this.beepDetected = false;
|
||||
}
|
||||
|
||||
startDecisionTimer() {
|
||||
this.decisionTimer = setTimeout(this._onDecisionTimeout.bind(this), this.decisionTimeoutMs);
|
||||
this.noSpeechTimer = setTimeout(this._onNoSpeechTimeout.bind(this), this.noSpeechTimeoutMs);
|
||||
this.startToneTimer();
|
||||
}
|
||||
stopDecisionTimer() {
|
||||
this.decisionTimer && clearTimeout(this.decisionTimer);
|
||||
}
|
||||
stopNoSpeechTimer() {
|
||||
this.noSpeechTimer && clearTimeout(this.noSpeechTimer);
|
||||
}
|
||||
startToneTimer() {
|
||||
this.toneTimer = setTimeout(this._onToneTimeout.bind(this), this.toneTimeoutMs);
|
||||
}
|
||||
startGreetingCompletionTimer() {
|
||||
this.greetingCompletionTimer = setTimeout(
|
||||
this._onGreetingCompletionTimeout.bind(this),
|
||||
this.beepDetected ? 1000 : this.greetingCompletionTimeoutMs);
|
||||
}
|
||||
stopGreetingCompletionTimer() {
|
||||
this.greetingCompletionTimer && clearTimeout(this.greetingCompletionTimer);
|
||||
}
|
||||
restartGreetingCompletionTimer() {
|
||||
this.stopGreetingCompletionTimer();
|
||||
this.startGreetingCompletionTimer();
|
||||
}
|
||||
stopToneTimer() {
|
||||
this.toneTimer && clearTimeout(this.toneTimer);
|
||||
}
|
||||
stopAllTimers() {
|
||||
this.stopDecisionTimer();
|
||||
this.stopNoSpeechTimer();
|
||||
this.stopToneTimer();
|
||||
this.stopGreetingCompletionTimer();
|
||||
}
|
||||
_onDecisionTimeout() {
|
||||
this.emit(this.decision = AmdEvents.DecisionTimeout);
|
||||
this.stopNoSpeechTimer();
|
||||
}
|
||||
_onToneTimeout() {
|
||||
this.emit(AmdEvents.ToneTimeout);
|
||||
}
|
||||
_onNoSpeechTimeout() {
|
||||
this.emit(this.decision = AmdEvents.NoSpeechDetected);
|
||||
this.stopDecisionTimer();
|
||||
}
|
||||
_onGreetingCompletionTimeout() {
|
||||
this.emit(AmdEvents.MachineStoppedSpeaking);
|
||||
}
|
||||
|
||||
evaluateTranscription(evt) {
|
||||
if (this.decision) {
|
||||
/* at this point we are only listening for the machine to stop speaking */
|
||||
if (this.decision === AmdEvents.MachineDetected) {
|
||||
this.restartGreetingCompletionTimer();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.stopNoSpeechTimer();
|
||||
|
||||
this.logger.debug({evt}, 'Amd:evaluateTranscription - raw');
|
||||
const t = this.normalizeTranscription(evt, this.vendor, this.language);
|
||||
const hints = voicemailHints[this.language] || [];
|
||||
|
||||
this.logger.debug({t}, 'Amd:evaluateTranscription - normalized');
|
||||
|
||||
if (Array.isArray(t.alternatives) && t.alternatives.length > 0) {
|
||||
const wordCount = t.alternatives[0].transcript.split(' ').length;
|
||||
const final = t.is_final;
|
||||
|
||||
const foundHint = hints.find((h) => t.alternatives[0].transcript.includes(h));
|
||||
if (foundHint) {
|
||||
/* we detected a common voice mail greeting */
|
||||
this.logger.debug(`Amd:evaluateTranscription: found hint ${foundHint}`);
|
||||
this.emit(this.decision = AmdEvents.MachineDetected, {
|
||||
reason: 'hint',
|
||||
hint: foundHint,
|
||||
language: t.language_code
|
||||
});
|
||||
}
|
||||
else if (final && wordCount < this.thresholdWordCount) {
|
||||
/* a short greeting is typically a human */
|
||||
this.emit(this.decision = AmdEvents.HumanDetected, {
|
||||
reason: 'short greeting',
|
||||
greeting: t.alternatives[0].transcript,
|
||||
language: t.language_code
|
||||
});
|
||||
}
|
||||
else if (wordCount >= this.thresholdWordCount) {
|
||||
/* a long greeting is typically a machine */
|
||||
this.emit(this.decision = AmdEvents.MachineDetected, {
|
||||
reason: 'long greeting',
|
||||
greeting: t.alternatives[0].transcript,
|
||||
language: t.language_code
|
||||
});
|
||||
}
|
||||
|
||||
if (this.decision) {
|
||||
this.stopDecisionTimer();
|
||||
|
||||
if (this.decision === AmdEvents.MachineDetected) {
|
||||
/* if we detected a machine, then wait for greeting to end */
|
||||
this.startGreetingCompletionTimer();
|
||||
}
|
||||
}
|
||||
return this.decision;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = (logger) => {
|
||||
const startTranscribing = async(cs, ep, task) => {
|
||||
const {vendor, language} = ep.amd;
|
||||
ep.startTranscription({
|
||||
vendor,
|
||||
language,
|
||||
interim: true,
|
||||
bugname
|
||||
}).catch((err) => {
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
ep.amd = null;
|
||||
task.emit(AmdEvents.Error, err);
|
||||
logger.error(err, 'amd:_startTranscribing error');
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.STT_FAILURE,
|
||||
vendor: vendor,
|
||||
detail: err.message
|
||||
});
|
||||
}).catch((err) => logger.info({err}, 'Error generating alert for tts failure'));
|
||||
|
||||
};
|
||||
|
||||
const onEndOfUtterance = (cs, ep, task) => {
|
||||
logger.debug('amd:onEndOfUtterance');
|
||||
startTranscribing(cs, ep, task);
|
||||
};
|
||||
const onNoSpeechDetected = (cs, ep, task) => {
|
||||
logger.debug('amd:onNoSpeechDetected');
|
||||
ep.amd.stopAllTimers();
|
||||
task.emit(AmdEvents.NoSpeechDetected);
|
||||
};
|
||||
const onTranscription = (cs, ep, task, evt, fsEvent) => {
|
||||
if (fsEvent.getHeader('media-bugname') !== bugname) return;
|
||||
ep.amd?.evaluateTranscription(evt);
|
||||
};
|
||||
const onBeep = (cs, ep, task, evt, fsEvent) => {
|
||||
logger.debug({evt, fsEvent}, 'onBeep');
|
||||
const frequency = Math.floor(fsEvent.getHeader('Frequency'));
|
||||
const variance = Math.floor(fsEvent.getHeader('Frequency-variance'));
|
||||
task.emit('amd', {type: AmdEvents.ToneDetected, frequency, variance});
|
||||
if (ep.amd) {
|
||||
ep.amd.stopToneTimer();
|
||||
ep.amd.beepDetected = true;
|
||||
}
|
||||
ep.execute('avmd_stop').catch((err) => this.logger.info(err, 'Error stopping avmd'));
|
||||
};
|
||||
|
||||
const startAmd = async(cs, ep, task, opts) => {
|
||||
const amd = ep.amd = new Amd(logger, cs, opts);
|
||||
const {vendor, language, sttCredentials} = amd;
|
||||
const sttOpts = {};
|
||||
const hints = voicemailHints[language] || [];
|
||||
|
||||
/* set stt options */
|
||||
logger.info(`starting amd for vendor ${vendor} and language ${language}`);
|
||||
if ('google' === vendor) {
|
||||
sttOpts.GOOGLE_APPLICATION_CREDENTIALS = JSON.stringify(sttCredentials.credentials);
|
||||
sttOpts.GOOGLE_SPEECH_USE_ENHANCED = true;
|
||||
sttOpts.GOOGLE_SPEECH_HINTS = hints.join(',');
|
||||
if (opts.recognizer?.altLanguages) {
|
||||
sttOpts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = opts.recognizer.altLanguages.join(',');
|
||||
}
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, onTranscription.bind(null, cs, ep, task));
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance, onEndOfUtterance.bind(null, cs, ep, task));
|
||||
}
|
||||
else if (['aws', 'polly'].includes(vendor)) {
|
||||
Object.assign(sttOpts, {
|
||||
AWS_ACCESS_KEY_ID: sttCredentials.accessKeyId,
|
||||
AWS_SECRET_ACCESS_KEY: sttCredentials.secretAccessKey,
|
||||
AWS_REGION: sttCredentials.region
|
||||
});
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, onTranscription.bind(null, cs, ep, task));
|
||||
}
|
||||
else if ('microsoft' === vendor) {
|
||||
Object.assign(sttOpts, {
|
||||
'AZURE_SUBSCRIPTION_KEY': sttCredentials.api_key,
|
||||
'AZURE_REGION': sttCredentials.region
|
||||
});
|
||||
sttOpts.AZURE_SPEECH_HINTS = hints.join(',');
|
||||
if (opts.recognizer?.altLanguages) {
|
||||
sttOpts.AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = opts.recognizer.altLanguages.join(',');
|
||||
}
|
||||
sttOpts.AZURE_INITIAL_SPEECH_TIMEOUT_MS = opts.resolveTimeoutMs || 20000;
|
||||
|
||||
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription, onTranscription.bind(null, cs, ep, task));
|
||||
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected, onNoSpeechDetected.bind(null, cs, ep, task));
|
||||
}
|
||||
logger.debug({sttOpts}, 'startAmd: setting channel vars');
|
||||
await ep.set(sttOpts).catch((err) => logger.info(err, 'Error setting channel variables'));
|
||||
|
||||
amd
|
||||
.on(AmdEvents.NoSpeechDetected, (evt) => {
|
||||
task.emit('amd', {type: AmdEvents.NoSpeechDetected, ...evt});
|
||||
ep.stopTranscription({vendor, bugname});
|
||||
})
|
||||
.on(AmdEvents.HumanDetected, (evt) => {
|
||||
task.emit('amd', {type: AmdEvents.HumanDetected, ...evt});
|
||||
ep.stopTranscription({vendor, bugname});
|
||||
})
|
||||
.on(AmdEvents.MachineDetected, (evt) => {
|
||||
task.emit('amd', {type: AmdEvents.MachineDetected, ...evt});
|
||||
})
|
||||
.on(AmdEvents.DecisionTimeout, (evt) => {
|
||||
task.emit('amd', {type: AmdEvents.DecisionTimeout, ...evt});
|
||||
ep.stopTranscription({vendor, bugname});
|
||||
})
|
||||
.on(AmdEvents.ToneTimeout, (evt) => {
|
||||
//task.emit('amd', {type: AmdEvents.ToneTimeout, ...evt});
|
||||
ep.execute('avmd_stop').catch((err) => logger.info(err, 'Error stopping avmd'));
|
||||
})
|
||||
.on(AmdEvents.MachineStoppedSpeaking, () => {
|
||||
task.emit('amd', {type: AmdEvents.MachineStoppedSpeaking});
|
||||
ep.stopTranscription({vendor, bugname});
|
||||
});
|
||||
|
||||
/* start transcribing, and also listening for beep */
|
||||
amd.startDecisionTimer();
|
||||
startTranscribing(cs, ep, task);
|
||||
|
||||
ep.addCustomEventListener(AvmdEvents.Beep, onBeep.bind(null, cs, ep, task));
|
||||
ep.execute('avmd_start').catch((err) => this.logger.info(err, 'Error starting avmd'));
|
||||
};
|
||||
|
||||
const stopAmd = (ep, task) => {
|
||||
let vendor;
|
||||
if (ep.amd) {
|
||||
vendor = ep.amd.vendor;
|
||||
ep.amd.stopAllTimers();
|
||||
ep.amd = null;
|
||||
}
|
||||
|
||||
if (ep.connected) {
|
||||
ep.stopTranscription({vendor, bugname})
|
||||
.catch((err) => logger.info(err, 'stopAmd: Error stopping transcription'));
|
||||
task.emit('amd', {type: AmdEvents.Stopped});
|
||||
ep.execute('avmd_stop').catch((err) => this.logger.info(err, 'Error stopping avmd'));
|
||||
}
|
||||
ep.removeCustomEventListener(AvmdEvents.Beep);
|
||||
};
|
||||
|
||||
return {startAmd, stopAmd};
|
||||
};
|
||||
@@ -20,6 +20,7 @@
|
||||
"Redirect": "redirect",
|
||||
"RestDial": "rest:dial",
|
||||
"SipDecline": "sip:decline",
|
||||
"SipRequest": "sip:request",
|
||||
"SipRefer": "sip:refer",
|
||||
"SipNotify": "sip:notify",
|
||||
"SipRedirect": "sip:redirect",
|
||||
@@ -28,6 +29,7 @@
|
||||
"Tag": "tag",
|
||||
"Transcribe": "transcribe"
|
||||
},
|
||||
"AllowedSipRecVerbs": ["config", "gather", "transcribe", "listen"],
|
||||
"CallStatus": {
|
||||
"Trying": "trying",
|
||||
"Ringing": "ringing",
|
||||
@@ -55,6 +57,9 @@
|
||||
"StableCall": "stable-call",
|
||||
"UnansweredCall": "unanswered-call"
|
||||
},
|
||||
"AvmdEvents": {
|
||||
"Beep": "avmd::beep"
|
||||
},
|
||||
"GoogleTranscriptionEvents": {
|
||||
"Transcription": "google_transcribe::transcription",
|
||||
"EndOfUtterance": "google_transcribe::end_of_utterance",
|
||||
@@ -119,6 +124,22 @@
|
||||
"verb:hook",
|
||||
"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,
|
||||
"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,9 +1,11 @@
|
||||
const bent = require('bent');
|
||||
const {Client, Pool} = require('undici');
|
||||
const parseUrl = require('parse-url');
|
||||
const assert = require('assert');
|
||||
const BaseRequestor = require('./base-requestor');
|
||||
const {HookMsgTypes} = require('./constants.json');
|
||||
const snakeCaseKeys = require('./snakecase-keys');
|
||||
const pools = new Map();
|
||||
const HTTP_TIMEOUT = 10000;
|
||||
|
||||
const toBase64 = (str) => Buffer.from(str || '', 'utf8').toString('base64');
|
||||
|
||||
@@ -22,22 +24,41 @@ class HttpRequestor extends BaseRequestor {
|
||||
this.method = hook.method || 'POST';
|
||||
this.authHeader = basicAuth(hook.username, hook.password);
|
||||
|
||||
const u = parseUrl(this.url);
|
||||
const myPort = u.port ? `:${u.port}` : '';
|
||||
const baseUrl = this._baseUrl = `${u.protocol}://${u.resource}${myPort}`;
|
||||
|
||||
this.get = bent(baseUrl, 'GET', 'buffer', 200, 201);
|
||||
this.post = bent(baseUrl, 'POST', 'buffer', 200, 201);
|
||||
|
||||
|
||||
assert(this._isAbsoluteUrl(this.url));
|
||||
assert(['GET', 'POST'].includes(this.method));
|
||||
|
||||
const u = this._parsedUrl = parseUrl(this.url);
|
||||
this._baseUrl = `${u.protocol}://${u.resource}`;
|
||||
this._resource = u.resource;
|
||||
this._protocol = u.protocol;
|
||||
this._usePools = process.env.HTTP_POOL && parseInt(process.env.HTTP_POOL);
|
||||
|
||||
if (this._usePools) {
|
||||
if (pools.has(this._baseUrl)) {
|
||||
this.client = pools.get(this._baseUrl);
|
||||
}
|
||||
else {
|
||||
const connections = process.env.HTTP_POOLSIZE ? parseInt(process.env.HTTP_POOLSIZE) : 10;
|
||||
const pipelining = process.env.HTTP_PIPELINING ? parseInt(process.env.HTTP_PIPELINING) : 1;
|
||||
const pool = this.client = new Pool(this._baseUrl, {
|
||||
connections,
|
||||
pipelining
|
||||
});
|
||||
pools.set(this._baseUrl, pool);
|
||||
this.logger.debug(`HttpRequestor:created pool for ${this._baseUrl}`);
|
||||
}
|
||||
}
|
||||
else this.client = new Client(`${u.protocol}://${u.resource}`);
|
||||
}
|
||||
|
||||
get baseUrl() {
|
||||
return this._baseUrl;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this._usePools && !this.client?.closed) this.client.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an HTTP request.
|
||||
* All requests use json bodies.
|
||||
@@ -58,6 +79,7 @@ class HttpRequestor extends BaseRequestor {
|
||||
const payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null;
|
||||
const url = hook.url || hook;
|
||||
const method = hook.method || 'POST';
|
||||
let buf = '';
|
||||
|
||||
assert.ok(url, 'HttpRequestor:request url was not provided');
|
||||
assert.ok, (['GET', 'POST'].includes(method), `HttpRequestor:request method must be 'GET' or 'POST' not ${method}`);
|
||||
@@ -65,14 +87,50 @@ class HttpRequestor extends BaseRequestor {
|
||||
this.logger.debug({url: urlInfo, method: methodInfo, payload}, `HttpRequestor:request ${method} ${url}`);
|
||||
const startAt = process.hrtime();
|
||||
|
||||
let buf;
|
||||
let newClient;
|
||||
try {
|
||||
let client, path;
|
||||
if (this._isRelativeUrl(url)) {
|
||||
client = this.client;
|
||||
path = url;
|
||||
}
|
||||
else {
|
||||
const u = parseUrl(url);
|
||||
if (u.resource === this._resource && u.protocol === this._protocol) {
|
||||
client = this.client;
|
||||
path = u.pathname;
|
||||
}
|
||||
else {
|
||||
client = newClient = new Client(`${u.protocol}://${u.resource}`);
|
||||
path = u.pathname;
|
||||
}
|
||||
}
|
||||
const sigHeader = this._generateSigHeader(payload, this.secret);
|
||||
const headers = {...sigHeader, ...this.authHeader, ...httpHeaders};
|
||||
this.logger.debug({url, headers}, 'send webhook');
|
||||
buf = this._isRelativeUrl(url) ?
|
||||
await this.post(url, payload, headers) :
|
||||
await bent(method, 'buffer', 200, 201, 202)(url, payload, headers);
|
||||
const hdrs = {
|
||||
...sigHeader,
|
||||
...this.authHeader,
|
||||
...httpHeaders,
|
||||
...('POST' === method && {'Content-Type': 'application/json'})
|
||||
};
|
||||
const absUrl = this._isRelativeUrl(url) ? `${this.baseUrl}${url}` : url;
|
||||
this.logger.debug({url, absUrl, hdrs}, 'send webhook');
|
||||
const {statusCode, headers, body} = await client.request({
|
||||
path,
|
||||
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},
|
||||
@@ -94,20 +152,15 @@ class HttpRequestor extends BaseRequestor {
|
||||
}
|
||||
this.Alerter.writeAlerts(opts).catch((err) => this.logger.info({err, opts}, 'Error writing alert'));
|
||||
|
||||
if (newClient) newClient.close();
|
||||
throw err;
|
||||
}
|
||||
const rtt = this._roundTrip(startAt);
|
||||
if (buf) this.stats.histogram('app.hook.response_time', rtt, ['hook_type:app']);
|
||||
|
||||
if (buf && buf.toString().length > 0) {
|
||||
try {
|
||||
const json = JSON.parse(buf.toString());
|
||||
this.logger.info({response: json}, `HttpRequestor:request ${method} ${url} succeeded in ${rtt}ms`);
|
||||
return json;
|
||||
}
|
||||
catch (err) {
|
||||
//this.logger.debug({err, url, method}, `HttpRequestor:request returned non-JSON content: '${buf.toString()}'`);
|
||||
}
|
||||
if (buf && Array.isArray(buf)) {
|
||||
this.logger.info({response: buf}, `HttpRequestor:request ${method} ${url} succeeded in ${rtt}ms`);
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
248
lib/utils/siprec-utils.js
Normal file
248
lib/utils/siprec-utils.js
Normal file
@@ -0,0 +1,248 @@
|
||||
const xmlParser = require('xml2js').parseString;
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const parseUri = require('drachtio-srf').parseUri;
|
||||
const transform = require('sdp-transform');
|
||||
const debug = require('debug')('jambonz:feature-server');
|
||||
|
||||
const parseCallData = (prefix, obj) => {
|
||||
const ret = {};
|
||||
const group = obj[`${prefix}group`];
|
||||
if (group) {
|
||||
const key = Object.keys(group[0]).find((k) => /:?callData$/.test(k));
|
||||
//const o = _.find(group[0], (value, key) => /:?callData$/.test(key));
|
||||
if (key) {
|
||||
//const callData = o[0];
|
||||
const callData = group[0][key];
|
||||
for (const key of Object.keys(callData)) {
|
||||
if (['fromhdr', 'tohdr', 'callid'].includes(key)) ret[key] = callData[key][0];
|
||||
}
|
||||
}
|
||||
}
|
||||
debug('parseCallData', prefix, obj, ret);
|
||||
return ret;
|
||||
};
|
||||
|
||||
/**
|
||||
* parse a SIPREC multiparty body
|
||||
* @param {object} opts - options
|
||||
* @return {Promise}
|
||||
*/
|
||||
const parseSiprecPayload = (req, logger) => {
|
||||
const opts = {};
|
||||
return new Promise((resolve, reject) => {
|
||||
let sdp, meta ;
|
||||
for (let i = 0; i < req.payload.length; i++) {
|
||||
switch (req.payload[i].type) {
|
||||
case 'application/sdp':
|
||||
sdp = req.payload[i].content ;
|
||||
break ;
|
||||
|
||||
case 'application/rs-metadata+xml':
|
||||
case 'application/rs-metadata':
|
||||
meta = opts.xml = req.payload[i].content ;
|
||||
break ;
|
||||
|
||||
default:
|
||||
break ;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sdp || !meta) {
|
||||
logger.info({payload: req.payload}, 'invalid SIPREC payload');
|
||||
return reject(new Error('expected multipart SIPREC body'));
|
||||
}
|
||||
|
||||
xmlParser(meta, (err, result) => {
|
||||
if (err) { throw err; }
|
||||
|
||||
opts.recordingData = result ;
|
||||
opts.sessionId = uuidv4() ;
|
||||
|
||||
const arr = /^([^]+)(m=[^]+?)(m=[^]+?)$/.exec(sdp) ;
|
||||
opts.sdp1 = `${arr[1]}${arr[2]}` ;
|
||||
opts.sdp2 = `${arr[1]}${arr[3]}\r\n` ;
|
||||
|
||||
try {
|
||||
if (typeof result === 'object' && Object.keys(result).length === 1) {
|
||||
const key = Object.keys(result)[0] ;
|
||||
const arr = /^(.*:)recording/.exec(key) ;
|
||||
const prefix = !arr ? '' : (arr[1]) ;
|
||||
const obj = opts.recordingData[`${prefix}recording`];
|
||||
|
||||
// 1. collect participant data
|
||||
const participants = {} ;
|
||||
obj[`${prefix}participant`].forEach((p) => {
|
||||
const partDetails = {} ;
|
||||
participants[p.$.participant_id] = partDetails;
|
||||
if ((`${prefix}nameID` in p) && Array.isArray(p[`${prefix}nameID`])) {
|
||||
partDetails.aor = p[`${prefix}nameID`][0].$.aor;
|
||||
if ('name' in p[`${prefix}nameID`][0] && Array.isArray(p[`${prefix}nameID`][0].name)) {
|
||||
const name = p[`${prefix}nameID`][0].name[0];
|
||||
if (typeof name === 'string') partDetails.name = name ;
|
||||
else if (typeof name === 'object') partDetails.name = name._ ;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. find the associated streams for each participant
|
||||
if (`${prefix}participantstreamassoc` in obj) {
|
||||
obj[`${prefix}participantstreamassoc`].forEach((ps) => {
|
||||
const part = participants[ps.$.participant_id];
|
||||
if (part) {
|
||||
part.send = ps[`${prefix}send`][0];
|
||||
part.recv = ps[`${prefix}recv`][0];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Retrieve stream data
|
||||
opts.caller = {} ;
|
||||
opts.callee = {} ;
|
||||
obj[`${prefix}stream`].forEach((s) => {
|
||||
const streamId = s.$.stream_id;
|
||||
let sender;
|
||||
for (const [k, v] of Object.entries(participants)) {
|
||||
if (v.send === streamId) {
|
||||
sender = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
//const sender = _.find(participants, { 'send': streamId});
|
||||
|
||||
if (!sender) return;
|
||||
|
||||
sender.label = s[`${prefix}label`][0];
|
||||
|
||||
if (-1 !== ['1', 'a_leg', 'inbound'].indexOf(sender.label)) {
|
||||
opts.caller.aor = sender.aor ;
|
||||
if (sender.name) opts.caller.name = sender.name;
|
||||
}
|
||||
else {
|
||||
opts.callee.aor = sender.aor ;
|
||||
if (sender.name) opts.callee.name = sender.name;
|
||||
}
|
||||
});
|
||||
|
||||
// if we dont have a participantstreamassoc then assume the first participant is the caller
|
||||
if (!opts.caller.aor && !opts.callee.aor) {
|
||||
let i = 0;
|
||||
for (const part in participants) {
|
||||
const p = participants[part];
|
||||
if (0 === i && p.aor) {
|
||||
opts.caller.aor = p.aor;
|
||||
opts.caller.name = p.name;
|
||||
}
|
||||
else if (1 === i && p.aor) {
|
||||
opts.callee.aor = p.aor;
|
||||
opts.callee.name = p.name;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// now for Sonus (at least) we get the original from, to and call-id headers in a <callData/> element
|
||||
// if so, this should take preference
|
||||
const callData = parseCallData(prefix, obj);
|
||||
if (callData) {
|
||||
debug(`callData: ${JSON.stringify(callData)}`);
|
||||
opts.originalCallId = callData.callid;
|
||||
|
||||
// caller
|
||||
let r1 = /^(.*)(<sip.*)$/.exec(callData.fromhdr);
|
||||
if (r1) {
|
||||
const arr = /<(.*)>/.exec(r1[2]);
|
||||
if (arr) {
|
||||
const uri = parseUri(arr[1]);
|
||||
const user = uri.user || 'anonymous';
|
||||
opts.caller.aor = `sip:${user}@${uri.host}`;
|
||||
}
|
||||
const dname = r1[1].trim();
|
||||
const arr2 = /"(.*)"/.exec(dname);
|
||||
if (arr2) opts.caller.name = arr2[1];
|
||||
else opts.caller.name = dname;
|
||||
}
|
||||
// callee
|
||||
r1 = /^(.*)(<sip.*)$/.exec(callData.tohdr);
|
||||
if (r1) {
|
||||
const arr = /<(.*)>/.exec(r1[2]);
|
||||
if (arr) {
|
||||
const uri = parseUri(arr[1]);
|
||||
opts.callee.aor = `sip:${uri.user}@${uri.host}`;
|
||||
}
|
||||
const dname = r1[1].trim();
|
||||
const arr2 = /"(.*)"/.exec(dname);
|
||||
if (arr2) opts.callee.name = arr2[1];
|
||||
else opts.callee.name = dname;
|
||||
}
|
||||
debug(`opts.caller from callData: ${JSON.stringify(opts.caller)}`);
|
||||
debug(`opts.callee from callData: ${JSON.stringify(opts.callee)}`);
|
||||
}
|
||||
|
||||
if (opts.caller.aor && 0 !== opts.caller.aor.indexOf('sip:')) {
|
||||
opts.caller.aor = 'sip:' + opts.caller.aor;
|
||||
}
|
||||
if (opts.callee.aor && 0 !== opts.callee.aor.indexOf('sip:')) {
|
||||
opts.callee.aor = 'sip:' + opts.callee.aor;
|
||||
}
|
||||
|
||||
if (opts.caller.aor) {
|
||||
const uri = parseUri(opts.caller.aor);
|
||||
opts.caller.number = uri.user;
|
||||
}
|
||||
if (opts.callee.aor) {
|
||||
const uri = parseUri(opts.callee.aor);
|
||||
opts.callee.number = uri.user;
|
||||
}
|
||||
opts.recordingSessionId = opts.recordingData[`${prefix}recording`][`${prefix}session`][0].$.session_id;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
debug(opts, 'payload parser results');
|
||||
resolve(opts) ;
|
||||
}) ;
|
||||
}) ;
|
||||
};
|
||||
|
||||
const createSipRecPayload = (sdp1, sdp2, logger) => {
|
||||
const sdpObj = [];
|
||||
sdpObj.push(transform.parse(sdp1));
|
||||
sdpObj.push(transform.parse(sdp2));
|
||||
|
||||
//const arr1 = /^([^]+)(c=[^]+)t=[^]+(m=[^]+?)(a=[^]+)$/.exec(sdp1) ;
|
||||
//const arr2 = /^([^]+)(c=[^]+)t=[^]+(m=[^]+?)(a=[^]+)$/.exec(sdp2) ;
|
||||
|
||||
debug(`sdp1: ${sdp1}`);
|
||||
debug(`objSdp[0]: ${JSON.stringify(sdpObj[0])}`);
|
||||
debug(`sdp2: ${sdp2}`);
|
||||
debug(`objSdp[1]: ${JSON.stringify(sdpObj[1])}`);
|
||||
|
||||
if (!sdpObj[0] || !sdpObj[0].media.length) {
|
||||
throw new Error(`Error parsing sdp1 into component parts: ${sdp1}`);
|
||||
}
|
||||
else if (!sdpObj[1] || !sdpObj[1].media.length) {
|
||||
throw new Error(`Error parsing sdp2 into component parts: ${sdp2}`);
|
||||
}
|
||||
|
||||
if (!sdpObj[0].media[0].label) sdpObj[0].media[0].label = 1;
|
||||
if (!sdpObj[1].media[0].label) sdpObj[1].media[0].label = 2;
|
||||
|
||||
//const aLabel = sdp1.includes('a=label:') ? '' : 'a=label:1\r\n';
|
||||
//const bLabel = sdp2.includes('a=label:') ? '' : 'a=label:2\r\n';
|
||||
|
||||
sdpObj[0].media = sdpObj[0].media.concat(sdpObj[1].media);
|
||||
const combinedSdp = transform.write(sdpObj[0])
|
||||
.replace(/a=sendonly\r\n/g, '')
|
||||
.replace(/a=direction:both\r\n/g, '');
|
||||
|
||||
debug(`combined ${combinedSdp}`);
|
||||
/*
|
||||
const combinedSdp = `${arr1[1]}t=0 0\r\n${arr1[2]}${arr1[3]}${arr1[4]}${aLabel}${arr2[3]}${arr2[4]}${bLabel}`
|
||||
.replace(/a=sendonly\r\n/g, '')
|
||||
.replace(/a=direction:both\r\n/g, '');
|
||||
*/
|
||||
return combinedSdp;
|
||||
};
|
||||
|
||||
module.exports = { parseSiprecPayload, createSipRecPayload } ;
|
||||
33
lib/utils/transcription-utils.js
Normal file
33
lib/utils/transcription-utils.js
Normal file
@@ -0,0 +1,33 @@
|
||||
module.exports = (logger) => {
|
||||
const normalizeTranscription = (evt, vendor, channel) => {
|
||||
if ('aws' === vendor && Array.isArray(evt) && evt.length > 0) evt = evt[0];
|
||||
if ('microsoft' === vendor) {
|
||||
const nbest = evt.NBest;
|
||||
const language_code = evt.PrimaryLanguage?.Language || this.language;
|
||||
const alternatives = nbest ? nbest.map((n) => {
|
||||
return {
|
||||
confidence: n.Confidence,
|
||||
transcript: n.Display
|
||||
};
|
||||
}) :
|
||||
[
|
||||
{
|
||||
transcript: evt.DisplayText || evt.Text
|
||||
}
|
||||
];
|
||||
|
||||
const newEvent = {
|
||||
is_final: evt.RecognitionStatus === 'Success',
|
||||
channel,
|
||||
language_code,
|
||||
alternatives
|
||||
};
|
||||
evt = newEvent;
|
||||
}
|
||||
evt.channel_tag = channel;
|
||||
//logger.debug({evt}, 'normalized transcription');
|
||||
return evt;
|
||||
};
|
||||
|
||||
return {normalizeTranscription};
|
||||
};
|
||||
@@ -158,7 +158,7 @@ class WsRequestor extends BaseRequestor {
|
||||
|
||||
close() {
|
||||
this.closedGracefully = true;
|
||||
this.logger.info('WsRequestor:close closing socket');
|
||||
this.logger.debug('WsRequestor:close closing socket');
|
||||
try {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
|
||||
4813
package-lock.json
generated
4813
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
59
package.json
59
package.json
@@ -21,57 +21,52 @@
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node app",
|
||||
"test": "NODE_ENV=test JAMBONES_HOSTING=1 DRACHTIO_HOST=127.0.0.1 DRACHTIO_PORT=9060 DRACHTIO_SECRET=cymru JAMBONES_MYSQL_HOST=127.0.0.1 JAMBONES_MYSQL_PORT=3360 JAMBONES_MYSQL_USER=jambones_test JAMBONES_MYSQL_PASSWORD=jambones_test JAMBONES_MYSQL_DATABASE=jambones_test JAMBONES_REDIS_HOST=127.0.0.1 JAMBONES_REDIS_PORT=16379 JAMBONES_LOGLEVEL=info ENABLE_METRICS=0 HTTP_PORT=3000 JAMBONES_SBCS=172.38.0.10 JAMBONES_FREESWITCH=127.0.0.1:8022:ClueCon:docker-host JAMBONES_TIME_SERIES_HOST=127.0.0.1 JAMBONES_NETWORK_CIDR=172.38.0.0/16 node test/ ",
|
||||
"test": "NODE_ENV=test JAMBONES_HOSTING=1 HTTP_POOL=1 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:ClueCon:docker-host JAMBONES_TIME_SERIES_HOST=127.0.0.1 JAMBONES_NETWORK_CIDR=172.38.0.0/16 node test/ ",
|
||||
"coverage": "./node_modules/.bin/nyc --reporter html --report-dir ./coverage npm run test",
|
||||
"jslint": "eslint app.js lib"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cognigy/socket-client": "^4.5.5",
|
||||
"@jambonz/db-helpers": "^0.6.16",
|
||||
"@jambonz/db-helpers": "^0.6.18",
|
||||
"@jambonz/http-health-check": "^0.0.1",
|
||||
"@jambonz/mw-registrar": "^0.2.1",
|
||||
"@jambonz/realtimedb-helpers": "^0.4.27",
|
||||
"@jambonz/realtimedb-helpers": "^0.4.29",
|
||||
"@jambonz/stats-collector": "^0.1.6",
|
||||
"@jambonz/time-series": "^0.1.6",
|
||||
"@jambonz/time-series": "^0.1.9",
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
"@opentelemetry/exporter-jaeger": "^1.1.0",
|
||||
"@opentelemetry/exporter-jaeger": "^1.3.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.27.0",
|
||||
"@opentelemetry/exporter-zipkin": "^1.1.0",
|
||||
"@opentelemetry/exporter-zipkin": "^1.3.1",
|
||||
"@opentelemetry/instrumentation": "^0.27.0",
|
||||
"@opentelemetry/instrumentation-express": "^0.28.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.27.0",
|
||||
"@opentelemetry/instrumentation-pino": "^0.28.1",
|
||||
"@opentelemetry/resources": "^1.1.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.1.0",
|
||||
"@opentelemetry/sdk-trace-node": "^1.1.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.1.0",
|
||||
"aws-sdk": "^2.1073.0",
|
||||
"@opentelemetry/resources": "^1.3.1",
|
||||
"@opentelemetry/sdk-trace-base": "^1.3.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.3.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.3.1",
|
||||
"aws-sdk": "^2.1152.0",
|
||||
"bent": "^7.3.12",
|
||||
"cidr-matcher": "^2.1.1",
|
||||
"debug": "^4.3.2",
|
||||
"debug": "^4.3.4",
|
||||
"deepcopy": "^2.1.0",
|
||||
"drachtio-fsmrf": "^2.0.13",
|
||||
"drachtio-srf": "^4.4.61",
|
||||
"express": "^4.17.1",
|
||||
"helmet": "^5.0.2",
|
||||
"ip": "^1.1.5",
|
||||
"moment": "^2.29.2",
|
||||
"parse-url": "^5.0.7",
|
||||
"pino": "^6.13.4",
|
||||
"drachtio-fsmrf": "^3.0.2",
|
||||
"drachtio-srf": "^4.5.1",
|
||||
"express": "^4.18.1",
|
||||
"helmet": "^5.1.0",
|
||||
"ip": "^1.1.8",
|
||||
"moment": "^2.29.3",
|
||||
"parse-url": "^7.0.2",
|
||||
"pino": "^6.14.0",
|
||||
"sdp-transform": "^2.14.1",
|
||||
"short-uuid": "^4.2.0",
|
||||
"to-snake-case": "^1.0.0",
|
||||
"undici": "^5.8.0",
|
||||
"uuid": "^8.3.2",
|
||||
"verify-aws-sns-signature": "^0.0.6",
|
||||
"ws": "^8.5.0",
|
||||
"verify-aws-sns-signature": "^0.0.7",
|
||||
"ws": "^8.8.0",
|
||||
"xml2js": "^0.4.23"
|
||||
},
|
||||
"devDependencies": {
|
||||
"async": "^3.2.0",
|
||||
"clear-module": "^4.1.1",
|
||||
"eslint": "^7.20.0",
|
||||
"clear-module": "^4.1.2",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-plugin-promise": "^4.3.1",
|
||||
"nyc": "^15.1.0",
|
||||
"tape": "^5.2.2"
|
||||
"tape": "^5.5.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.0.6",
|
||||
|
||||
52
test/create-call-test.js
Normal file
52
test/create-call-test.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const test = require('tape');
|
||||
const { sippUac } = require('./sipp')('test_fs');
|
||||
const bent = require('bent');
|
||||
const getJSON = bent('json')
|
||||
const clearModule = require('clear-module');
|
||||
const HttpRequestor = require('../lib/utils/http-requestor');
|
||||
|
||||
|
||||
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);
|
||||
let account_sid = '622f62e4-303a-49f2-bbe0-eb1e1714e37a'
|
||||
const post = bent('http://127.0.0.1:3000/', 'POST', 'json', 201)
|
||||
post('v1/createCall', {
|
||||
'account_sid':account_sid,
|
||||
'timeout': 1,
|
||||
"call_hook": {
|
||||
"url": "https://public-apps.jambonz.us/hello-world",
|
||||
"method": "POST"
|
||||
},
|
||||
"from": "15083718299",
|
||||
"to": {
|
||||
"type": "phone",
|
||||
"number": "15583084809"
|
||||
}})
|
||||
await sippUac('uas-timeout-cancel.xml', '172.38.0.10');
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -251,6 +251,7 @@ INSERT INTO `applications` VALUES ('24d0f6af-e976-44dd-a2e8-41c7b55abe33','say a
|
||||
INSERT INTO `applications` VALUES ('17461c69-56b5-4dab-ad83-1c43a0f93a3d','gather',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','10692465-a511-4277-9807-b7157e4f81e1','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US');
|
||||
INSERT INTO `applications` VALUES ('baf9213b-5556-4c20-870c-586392ed246f','transcribe',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','ecb67a8f-f7ce-4919-abf0-bbc69c1001e5','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US');
|
||||
INSERT INTO `applications` VALUES ('ae026ab5-3029-47b4-9d7c-236e3a4b4ebe','transcribe account 2',NULL,'622f62e4-303a-49f2-bbe0-eb1e1714e37a','ecb67a8f-f7ce-4919-abf0-bbc69c1001e5','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US');
|
||||
INSERT INTO `applications` VALUES ('195d9507-6a42-46a8-825f-f009e729d023','sip info',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','c9113e7a-741f-48b9-96c1-f2f78176eeb3','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US');
|
||||
/*!40000 ALTER TABLE `applications` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
|
||||
@@ -449,6 +450,7 @@ INSERT INTO `phone_numbers` VALUES ('e686a320-0725-418f-be65-532159bdc3ed','1617
|
||||
INSERT INTO `phone_numbers` VALUES ('05eeed62-b29b-4679-bf38-d7a4e318be44','16174000003','5145b436-2f38-4029-8d4c-fd8c67831c7a','bb845d4b-83a9-4cde-a6e9-50f3743bab3f','17461c69-56b5-4dab-ad83-1c43a0f93a3d', NULL);
|
||||
INSERT INTO `phone_numbers` VALUES ('f3c53863-b629-4cf6-9dcb-c7fb7072314b','16174000004','5145b436-2f38-4029-8d4c-fd8c67831c7a','bb845d4b-83a9-4cde-a6e9-50f3743bab3f','baf9213b-5556-4c20-870c-586392ed246f', NULL);
|
||||
INSERT INTO `phone_numbers` VALUES ('f6416c17-829a-4f11-9c32-f0d00e4a9ae9','16174000005','5145b436-2f38-4029-8d4c-fd8c67831c7a','622f62e4-303a-49f2-bbe0-eb1e1714e37a','ae026ab5-3029-47b4-9d7c-236e3a4b4ebe', NULL);
|
||||
INSERT INTO `phone_numbers` VALUES ('964d0581-9627-44cb-be20-8118050406b2','16174000006','5145b436-2f38-4029-8d4c-fd8c67831c7a','bb845d4b-83a9-4cde-a6e9-50f3743bab3f','195d9507-6a42-46a8-825f-f009e729d023', NULL);
|
||||
/*!40000 ALTER TABLE `phone_numbers` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
|
||||
@@ -736,6 +738,7 @@ INSERT INTO `webhooks` VALUES ('c71e79db-24f2-4866-a3ee-febb0f97b341','http://12
|
||||
INSERT INTO `webhooks` VALUES ('54ab0976-a6c0-45d8-89a4-d90d45bf9d96','http://127.0.0.1:3101/','POST',NULL,NULL);
|
||||
INSERT INTO `webhooks` VALUES ('10692465-a511-4277-9807-b7157e4f81e1','http://127.0.0.1:3102/','POST',NULL,NULL);
|
||||
INSERT INTO `webhooks` VALUES ('ecb67a8f-f7ce-4919-abf0-bbc69c1001e5','http://127.0.0.1:3103/','POST',NULL,NULL);
|
||||
INSERT INTO `webhooks` VALUES ('c9113e7a-741f-48b9-96c1-f2f78176eeb3','http://127.0.0.1:3104/','POST',NULL,NULL);
|
||||
/*!40000 ALTER TABLE `webhooks` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
|
||||
|
||||
@@ -123,6 +123,18 @@ services:
|
||||
fs:
|
||||
ipv4_address: 172.38.0.63
|
||||
|
||||
webhook-sip-info:
|
||||
image: jambonz/webhook-test-scaffold:latest
|
||||
environment:
|
||||
APP_PATH: /tmp/info.json
|
||||
ports:
|
||||
- "3104:3000/tcp"
|
||||
volumes:
|
||||
- ./test-apps:/tmp
|
||||
networks:
|
||||
fs:
|
||||
ipv4_address: 172.38.0.64
|
||||
|
||||
influxdb:
|
||||
image: influxdb:1.8
|
||||
ports:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const test = require('tape') ;
|
||||
const exec = require('child_process').exec ;
|
||||
const async = require('async');
|
||||
|
||||
test('starting docker network..takes a bit for mysql and freeswitch to come up..patience..', (t) => {
|
||||
exec(`docker-compose -f ${__dirname}/docker-compose-testbed.yaml up -d`, (err, stdout, stderr) => {
|
||||
|
||||
@@ -5,5 +5,7 @@ require('./account-validation-tests');
|
||||
require('./webhooks-tests');
|
||||
require('./say-tests');
|
||||
require('./gather-tests');
|
||||
require('./sip-request-tests');
|
||||
require('./create-call-test');
|
||||
require('./remove-test-db');
|
||||
require('./docker_stop');
|
||||
|
||||
107
test/scenarios/uac-send-info-during-dialog.xml
Normal file
107
test/scenarios/uac-send-info-during-dialog.xml
Normal file
@@ -0,0 +1,107 @@
|
||||
<?xml version="1.0" encoding="ISO-8859-1" ?>
|
||||
<!DOCTYPE scenario SYSTEM "sipp.dtd">
|
||||
|
||||
|
||||
<scenario name="Basic Sipstone UAC">
|
||||
<!-- In client mode (sipp placing calls), the Call-ID MUST be -->
|
||||
<!-- generated by sipp. To do so, use [call_id] keyword. -->
|
||||
<send retrans="500">
|
||||
<![CDATA[
|
||||
|
||||
INVITE sip:16174000006@[remote_ip]:[remote_port] SIP/2.0
|
||||
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
||||
From: sipp <sip:sipp@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: <sip:16174000006@[remote_ip]:[remote_port]>
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 INVITE
|
||||
Contact: sip:sipp@[local_ip]:[local_port]
|
||||
Max-Forwards: 70
|
||||
X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f
|
||||
Subject: uac-gather-account-creds-success
|
||||
Content-Type: application/sdp
|
||||
Content-Length: [len]
|
||||
|
||||
v=0
|
||||
o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip]
|
||||
s=-
|
||||
c=IN IP[media_ip_type] [media_ip]
|
||||
t=0 0
|
||||
m=audio [media_port] RTP/AVP 0
|
||||
a=rtpmap:0 PCMU/8000
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<recv response="100"
|
||||
optional="true">
|
||||
</recv>
|
||||
|
||||
<recv response="180" optional="true">
|
||||
</recv>
|
||||
|
||||
<recv response="183" optional="true">
|
||||
</recv>
|
||||
|
||||
<!-- By adding rrs="true" (Record Route Sets), the route sets -->
|
||||
<!-- are saved and used for following messages sent. Useful to test -->
|
||||
<!-- against stateful SIP proxies/B2BUAs. -->
|
||||
<recv response="200" rtd="true">
|
||||
</recv>
|
||||
|
||||
<!-- Packet lost can be simulated in any send/recv message by -->
|
||||
<!-- by adding the 'lost = "10"'. Value can be [1-100] percent. -->
|
||||
<send>
|
||||
<![CDATA[
|
||||
|
||||
ACK sip:16174000006@[remote_ip]:[remote_port] SIP/2.0
|
||||
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
||||
From: sipp <sip:sipp@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
|
||||
To: 16174000006 <sip:16174000006@[remote_ip]:[remote_port]>[peer_tag_param]
|
||||
Call-ID: [call_id]
|
||||
CSeq: 1 ACK
|
||||
Contact: sip:sipp@[local_ip]:[local_port]
|
||||
Max-Forwards: 70
|
||||
Subject: uac-gather-account-creds-success
|
||||
Content-Length: 0
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<recv request="INFO">
|
||||
</recv>
|
||||
|
||||
<send>
|
||||
<![CDATA[
|
||||
|
||||
SIP/2.0 200 OK
|
||||
[last_Via:]
|
||||
[last_From:]
|
||||
[last_To:]
|
||||
[last_Call-ID:]
|
||||
[last_CSeq:]
|
||||
Contact: <sip:[local_ip]:[local_port];transport=[transport]>
|
||||
Content-Length: 0
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
|
||||
<recv request="BYE">
|
||||
</recv>
|
||||
|
||||
<send>
|
||||
<![CDATA[
|
||||
|
||||
SIP/2.0 200 OK
|
||||
[last_Via:]
|
||||
[last_From:]
|
||||
[last_To:]
|
||||
[last_Call-ID:]
|
||||
[last_CSeq:]
|
||||
Contact: <sip:[local_ip]:[local_port];transport=[transport]>
|
||||
Content-Length: 0
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
</scenario>
|
||||
99
test/scenarios/uas-timeout-cancel.xml
Normal file
99
test/scenarios/uas-timeout-cancel.xml
Normal file
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="ISO-8859-1" ?>
|
||||
<!DOCTYPE scenario SYSTEM "sipp.dtd">
|
||||
|
||||
<!-- This program is free software; you can redistribute it and/or -->
|
||||
<!-- modify it under the terms of the GNU General Public License as -->
|
||||
<!-- published by the Free Software Foundation; either version 2 of the -->
|
||||
<!-- License, or (at your option) any later version. -->
|
||||
<!-- -->
|
||||
<!-- This program is distributed in the hope that it will be useful, -->
|
||||
<!-- but WITHOUT ANY WARRANTY; without even the implied warranty of -->
|
||||
<!-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -->
|
||||
<!-- GNU General Public License for more details. -->
|
||||
<!-- -->
|
||||
<!-- You should have received a copy of the GNU General Public License -->
|
||||
<!-- along with this program; if not, write to the -->
|
||||
<!-- Free Software Foundation, Inc., -->
|
||||
<!-- 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -->
|
||||
<!-- -->
|
||||
<!-- Sipp default 'uas' scenario. -->
|
||||
<!-- -->
|
||||
|
||||
<scenario name="UAS Timeout Receive Cancel">
|
||||
<!-- By adding rrs="true" (Record Route Sets), the route sets -->
|
||||
<!-- are saved and used for following messages sent. Useful to test -->
|
||||
<!-- against stateful SIP proxies/B2BUAs. -->
|
||||
<recv request="INVITE" crlf="true">
|
||||
<action>
|
||||
<ereg regexp=".*" search_in="hdr" header="Subject:" assign_to="1" />
|
||||
<ereg regexp=".*" search_in="hdr" header="CSeq:" check_it="false" assign_to="2"/>
|
||||
</action>
|
||||
</recv>
|
||||
|
||||
<!-- The '[last_*]' keyword is replaced automatically by the -->
|
||||
<!-- specified header if it was present in the last message received -->
|
||||
<!-- (except if it was a retransmission). If the header was not -->
|
||||
<!-- present or if no message has been received, the '[last_*]' -->
|
||||
<!-- keyword is discarded, and all bytes until the end of the line -->
|
||||
<!-- are also discarded. -->
|
||||
<!-- -->
|
||||
<!-- If the specified header was present several times in the -->
|
||||
<!-- message, all occurrences are concatenated (CRLF separated) -->
|
||||
<!-- to be used in place of the '[last_*]' keyword. -->
|
||||
|
||||
<send>
|
||||
<![CDATA[
|
||||
|
||||
SIP/2.0 180 Ringing
|
||||
[last_Via:]
|
||||
[last_From:]
|
||||
[last_To:];tag=[pid]SIPpTag01[call_number]
|
||||
[last_Call-ID:]
|
||||
[last_CSeq:]
|
||||
[last_Record-Route:]
|
||||
Subject:[$1]
|
||||
Content-Length: 0
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<recv request="CANCEL" >
|
||||
</recv>
|
||||
|
||||
<send>
|
||||
<![CDATA[
|
||||
|
||||
SIP/2.0 200 OK
|
||||
[last_Via:]
|
||||
[last_From:]
|
||||
[last_To:];tag=[pid]SIPpTag01[call_number]
|
||||
[last_Call-ID:]
|
||||
[last_CSeq:]
|
||||
Subject:[$1]
|
||||
Contact: <sip:[local_ip]:[local_port];transport=[transport]>
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<send>
|
||||
<![CDATA[
|
||||
|
||||
SIP/2.0 487 Request Terminated
|
||||
[last_Via:]
|
||||
[last_From:]
|
||||
[last_To:];tag=[pid]SIPpTag01[call_number]
|
||||
[last_Call-ID:]
|
||||
CSeq: [$2]
|
||||
[last_Record-Route:]
|
||||
Subject:[$1]
|
||||
Contact: <sip:[local_ip]:[local_port];transport=[transport]>
|
||||
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<recv request="ACK"
|
||||
rtd="true"
|
||||
crlf="true">
|
||||
</recv>
|
||||
</scenario>
|
||||
|
||||
35
test/sip-request-tests.js
Normal file
35
test/sip-request-tests.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const test = require('tape');
|
||||
const { sippUac } = require('./sipp')('test_fs');
|
||||
const bent = require('bent');
|
||||
const getJSON = bent('json')
|
||||
const clearModule = require('clear-module');
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
});
|
||||
|
||||
function connect(connectable) {
|
||||
return new Promise((resolve, reject) => {
|
||||
connectable.on('connect', () => {
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('sending SIP in-dialog requests tests', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
await sippUac('uac-send-info-during-dialog.xml', '172.38.0.10');
|
||||
const obj = await getJSON('http://127.0.0.1:3104/actionHook');
|
||||
t.ok(obj.result === 'success' && obj.sip_status === 200, 'successfully sent SIP INFO');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
const test = require('blue-tape');
|
||||
const { output, sippUac } = require('./sipp')('test_sbc-inbound');
|
||||
const debug = require('debug')('drachtio:sbc-inbound');
|
||||
const clearModule = require('clear-module');
|
||||
const test = require('tape');
|
||||
const { sippUac } = require('./sipp')('test_sbc-inbound');
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
|
||||
15
test/test-apps/info.json
Normal file
15
test/test-apps/info.json
Normal file
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"verb": "say",
|
||||
"text": "hello"
|
||||
},
|
||||
{
|
||||
"verb": "sip:request",
|
||||
"method": "info",
|
||||
"headers": {
|
||||
"Content-Type": "application/text"
|
||||
},
|
||||
"body": "here I am ",
|
||||
"actionHook": "/actionHook"
|
||||
}
|
||||
]
|
||||
@@ -1,15 +1,23 @@
|
||||
FROM node:alpine as builder
|
||||
RUN apk update && apk add --no-cache python make g++
|
||||
WORKDIR /opt/app/
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
RUN npm prune
|
||||
FROM --platform=linux/amd64 node:18.6.0-alpine as base
|
||||
|
||||
FROM node:alpine as webapp
|
||||
RUN apk add curl
|
||||
WORKDIR /opt/app
|
||||
COPY . /opt/app
|
||||
COPY --from=builder /opt/app/node_modules ./node_modules
|
||||
COPY ./entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
RUN apk --update --no-cache add --virtual .builds-deps build-base python3
|
||||
|
||||
WORKDIR /opt/app/
|
||||
|
||||
FROM base as build
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
FROM base
|
||||
|
||||
COPY --from=build /opt/app /opt/app/
|
||||
|
||||
ARG NODE_ENV
|
||||
|
||||
ENV NODE_ENV $NODE_ENV
|
||||
|
||||
CMD [ "node", "app.js" ]
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/sh
|
||||
cd /opt/app/
|
||||
npm start
|
||||
487
test/webhook/package-lock.json
generated
487
test/webhook/package-lock.json
generated
@@ -1,8 +1,493 @@
|
||||
{
|
||||
"name": "webhook",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 1,
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "webhook",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.17.1"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.7",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz",
|
||||
"integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.24",
|
||||
"negotiator": "0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI="
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz",
|
||||
"integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.0",
|
||||
"content-type": "~1.0.4",
|
||||
"debug": "2.6.9",
|
||||
"depd": "~1.1.2",
|
||||
"http-errors": "1.7.2",
|
||||
"iconv-lite": "0.4.24",
|
||||
"on-finished": "~2.3.0",
|
||||
"qs": "6.7.0",
|
||||
"raw-body": "2.4.0",
|
||||
"type-is": "~1.6.17"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
|
||||
"integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz",
|
||||
"integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz",
|
||||
"integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz",
|
||||
"integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw="
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
|
||||
"integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz",
|
||||
"integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA="
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0="
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
||||
"integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg="
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.17.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz",
|
||||
"integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.7",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.19.0",
|
||||
"content-disposition": "0.5.3",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.4.0",
|
||||
"cookie-signature": "1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "~1.1.2",
|
||||
"encodeurl": "~1.0.2",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "~1.1.2",
|
||||
"fresh": "0.5.2",
|
||||
"merge-descriptors": "1.0.1",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "~2.3.0",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.7",
|
||||
"proxy-addr": "~2.0.5",
|
||||
"qs": "6.7.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.1.2",
|
||||
"send": "0.17.1",
|
||||
"serve-static": "1.14.1",
|
||||
"setprototypeof": "1.1.1",
|
||||
"statuses": "~1.5.0",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
|
||||
"integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~1.0.2",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "~2.3.0",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "~1.5.0",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz",
|
||||
"integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz",
|
||||
"integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==",
|
||||
"dependencies": {
|
||||
"depd": "~1.1.2",
|
||||
"inherits": "2.0.3",
|
||||
"setprototypeof": "1.1.1",
|
||||
"statuses": ">= 1.5.0 < 2",
|
||||
"toidentifier": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
|
||||
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
|
||||
"integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E="
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.45.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.45.0.tgz",
|
||||
"integrity": "sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.28",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.28.tgz",
|
||||
"integrity": "sha512-0TO2yJ5YHYr7M2zzT7gDU1tbwHxEUWBCLt0lscSNpcdAfFyJOVEpRYNS7EXVcTLNj/25QO8gulHC5JtTzSE2UQ==",
|
||||
"dependencies": {
|
||||
"mime-db": "1.45.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz",
|
||||
"integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
|
||||
"integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
|
||||
"integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz",
|
||||
"integrity": "sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw==",
|
||||
"dependencies": {
|
||||
"forwarded": "~0.1.2",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.7.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
|
||||
"integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz",
|
||||
"integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.0",
|
||||
"http-errors": "1.7.2",
|
||||
"iconv-lite": "0.4.24",
|
||||
"unpipe": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.17.1",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz",
|
||||
"integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "~1.1.2",
|
||||
"destroy": "~1.0.4",
|
||||
"encodeurl": "~1.0.2",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "~1.7.2",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.1",
|
||||
"on-finished": "~2.3.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "~1.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz",
|
||||
"integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg=="
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz",
|
||||
"integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==",
|
||||
"dependencies": {
|
||||
"encodeurl": "~1.0.2",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "0.17.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
|
||||
"integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
|
||||
"integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz",
|
||||
"integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"accepts": {
|
||||
"version": "1.3.7",
|
||||
|
||||
Reference in New Issue
Block a user