Compare commits

..

122 Commits

Author SHA1 Message Date
Dave Horton
5e5fb4c191 proper shut down in K8S (#254) 2023-02-07 19:54:17 -05:00
Dave Horton
de8688ced8 #251 - unhandled exception in Session#_notifyCallStatusChange 2023-02-07 14:48:07 -05:00
Dave Horton
528fefb5a0 update db-helpers 2023-02-07 13:45:51 -05:00
Dave Horton
990d0a7bca when closing websocket at end of call send ws code 1000 2023-01-22 12:47:18 -05:00
Dave Horton
db3d0504f1 bugfix #223: early hints should not be enabled with continuous asr is used in gather 2023-01-19 09:47:42 -05:00
Dave Horton
6149eff373 when clearing stack on background gather (bargein), search to see if … (#221)
* when clearing stack on background gather (bargein), search to see if there is a config further down the stack that turns bargein off, and if so clear only down to there (#220)

* avoid clearing the stack twice when a background gather bargein occurs

* fix bug from prev commit
2023-01-19 09:29:17 -05:00
Dave Horton
33b8bd701d fix and test cases for #217 - sip:refer blocks if NOTIFY not received after 202 Accepted (#218) 2023-01-13 12:07:46 -05:00
Dave Horton
a6b5366136 bump version 2023-01-12 16:17:42 -05:00
Dave Horton
902ed0b644 gh: run with lts node version 2023-01-09 10:12:40 -05:00
Dave Horton
978f556466 gh actions: run tests on pull request 2023-01-09 10:04:20 -05:00
Dave Horton
5e3bd91f8c Bugfix/gather kill race condition (#207)
* further fix for race condition in #206

* #206: ignore request to start bot mode when bot mode is already active
2023-01-04 09:24:39 -05:00
Dave Horton
050297825b fix #206: prevent 2 simultaneous background gathers 2023-01-03 10:07:04 -05:00
Dave Horton
1fcfe08f9b add support for http transport for jaeger 2022-12-30 10:45:11 -05:00
Dave Horton
9e7c8f207a update to latest drachtio-srf and realtimedb-helpers 2022-12-29 10:23:20 -05:00
Dave Horton
3397e1cab5 update to latest drachtio-fsmrf 2022-12-28 11:00:13 -06:00
Dave Horton
e7dbfe755d update package-lock.json 2022-12-28 10:46:31 -06:00
Dave Horton
e2ad0dca0e faster uuid 2022-12-28 10:45:33 -06:00
Dave Horton
e2c99609bf bump version 2022-12-24 12:04:49 -06:00
Dave Horton
4d54aa2666 update to latest drachtio-srf and drachtio-fsmrf 2022-12-21 12:19:29 -05:00
Dave Horton
a076fc43b5 faster uuid generator 2022-12-21 08:29:51 -05:00
Dave Horton
8592a71978 update to drachtio-fsmrf@0.3.11 with fix of regression performance fix 2022-12-14 11:54:33 -05:00
Dave Horton
00462b2fd9 update to drachtio-fsmrf@3.0.10 with performance improvements 2022-12-14 08:25:25 -05:00
Dave Horton
7c85d6aeca bugfix: db caching had side affects of using closed http requestors 2022-12-13 14:52:38 -05:00
Dave Horton
cc87b205a2 update gh actions 2022-12-10 15:32:08 -05:00
Dave Horton
fff556a6c8 update drachtio-fsmrf and srf with more efficient freeswitch call setup 2022-12-10 15:28:23 -05:00
Dave Horton
bb4ca8e467 bugfix: when handing over from wss to http close the wss socket 2022-12-09 10:55:22 -05:00
Dave Horton
46302703da further fix for #192, also bug fix for starting with a ws connection and switching to webhooks later in the same call 2022-12-05 10:53:41 -05:00
Dave Horton
c728417581 bugfix #192: config with dtmf only followed later by gather with speech not working 2022-12-01 14:06:29 -05:00
Dave Horton
8853f84f01 add custom header on Refer indicating whether sbc-inbound should fix up the Refer-To 2022-11-30 12:50:54 -05:00
Dave Horton
665d26b6fb bugfix: continuous asr timer in gather should not start until transcript is received 2022-11-29 11:37:41 -05:00
Dave Horton
d69c773de0 include service_provider_sid in call webhook 2022-11-29 11:27:20 -05:00
Dave Horton
21eaa442b2 add recognizer.azureServiceEndpoint for custom azure voices 2022-11-25 10:46:47 -05:00
Dave Horton
6484086222 feature: return transcript faster if we get an exact match to a provided hint on an interim transcript (requires env JAMBONZ_GATHER_EARLY_HINTS_MATCH=1) 2022-11-25 08:15:18 -05:00
Dave Horton
01645df920 error handling in amd 2022-11-22 15:40:26 -05:00
Guilherme Rauen
b2363b09c1 update node image to the latest and most secure (#189)
Co-authored-by: Guilherme Rauen <g.rauen@cognigy.com>
2022-11-11 17:45:26 -05:00
Dave Horton
c11d892f0a bugfix: microsoft tts voice was not being sent in tts request, resulting in a default voice being selected 2022-11-10 13:00:59 -05:00
Dave Horton
9fd116b05f fix for #186: unhandled error when amd webhook returns non-success status code 2022-11-05 10:27:00 -04:00
Dave Horton
19098aee98 fixes for custom voice testing in azure 2022-11-04 09:36:44 -04:00
Dave Horton
d15dbf7f5a update to synthAudio with support for Azure custom voices 2022-11-04 08:27:09 -04:00
Dave Horton
824f983955 update deps 2022-11-02 13:40:25 -04:00
Dave Horton
7c76bc52f6 update to db-helpers with caching fix 2022-11-01 20:57:18 -04:00
Dave Horton
bfc8a99950 bugfix: ws error max connections error causes a crash 2022-11-01 11:33:03 -04:00
Dave Horton
9097c6d6ac bugfix when running multiple instances in EC2 2022-10-31 14:42:53 -04:00
Dave Horton
15b2fdd5a8 update to db-helpers@0.7.0 with caching option 2022-10-31 11:43:07 -04:00
Dave Horton
979e17c814 add support for Azure audio logging in gather and transcribe 2022-10-31 11:08:16 -04:00
Dave Horton
70caf00dd1 Feature/multi forks on ec2 (#182)
* changes to allow multiple instances to run in an EC2 autoscale deployment

* fix health check

* fixup aws sns notification so it subscribes using bound port

* AWS SNS port range 3010-3019
2022-10-30 13:07:49 -04:00
Dave Horton
f044cdd150 bugfix: rest:dial with fromHost now working 2022-10-26 09:36:53 -04:00
Dave Horton
c3d39f0970 add support for fromHost in createCall rest api 2022-10-25 13:32:13 -04:00
Dave Horton
9c69a2c79f fix typo (again) 2022-10-24 18:35:15 -04:00
Dave Horton
e0607b9c2e feature: specify user or host part of From uri on outdial 2022-10-23 15:24:30 -04:00
Dave Horton
dc378cd065 update package-lock.json 2022-10-23 12:23:10 -04:00
Markus Frindt
138950c534 [snyk] fix vulnerabilities (#177)
Co-authored-by: Markus Frindt <m.frindt@cognigy.com>
2022-10-20 21:35:36 -04:00
Dave Horton
215a28b615 bugfix: conference verb will have '_' property when leg is moved from other FS 2022-10-20 12:25:50 -04:00
Dave Horton
3a5efa37b9 bugfix: to move call leg to a different FS using the special REFER, we now must include X-Account-Sid header 2022-10-15 10:52:56 -04:00
Dave Horton
917b8f332c minor logging 2022-10-14 12:53:44 -04:00
Dave Horton
17848ea22c bump version 2022-10-13 16:02:35 -04:00
Dave Horton
43af27e802 update time-series 2022-10-10 09:19:05 +01:00
Dave Horton
b25f92e17a Feature/azure custom stt (#171)
* gather/transcribe: support for azure custom speech models (endpoint id)

* allow azure stt custom speech endpoint id to be passed as property in recognizer

* fix to add custom stt endpoint to session speech credentials object
2022-10-07 09:46:25 +01:00
Dave Horton
90cb5e1348 bugfix: typo in bugname was causing transcripts to be ignored 2022-10-04 12:59:58 +01:00
Dave Horton
cf821569b3 minor logging changes 2022-10-02 22:36:27 +01:00
Dave Horton
218f2d6c67 bugfix: unnecessary call to stopTranscription in gather verb when only collecting digits 2022-09-30 10:27:33 +01:00
Joan
c2c8f00978 added call_termination_by on app call status (#168)
Co-authored-by: Joan Salvatella <joan@bookline.io>
2022-09-23 09:13:55 +02:00
Dave Horton
32714d73f3 update to synthAudio with bugfix for writing TTS rtt stats for microsoft 2022-09-21 15:22:19 +02:00
Dave Horton
8da85ebd5a include custom header X-Application-Sid to make it available to cdrs 2022-09-20 13:54:54 +02:00
Dave Horton
dcedf68264 regression bug with adding amd 2022-09-20 09:32:02 +02:00
Dave Horton
05c5d2211f regression bug with parse-url update 2022-09-20 09:31:44 +02:00
Dave Horton
0c089e2380 bugfix: config was not properly enabling amd when configured 2022-09-19 21:16:19 +02:00
Dave Horton
099f33857c update time-series and parse-url 2022-09-16 13:07:08 +02:00
Dave Horton
bd49dacac4 Say length text (#165)
* typo for media bug name in azure and punctuation fix

* say: split very long text intelligently

* more fixes from testing

* update to latest synthAudio
2022-09-14 17:17:29 +02:00
Dave Horton
876824abde typo for media bug name in azure and punctuation fix 2022-09-13 16:22:46 +02:00
Dave Horton
468a9e6d6b make maxPayload of websocket configurable via JAMBONES_WS_MAX_PAYLOAD 2022-09-13 12:35:31 +02:00
Dave Horton
c88163fe11 Bugfix/config stt punctuation (#164)
* support recognizer.punctuation in config verb (#163)

* fixes from testing
2022-09-13 11:45:36 +02:00
xquanluu
bf7ece8f17 feat: play verb support seekOffset and actionHook (#160)
* feat: play verb support seekOffset and actionHook

* add testcase

* fix: testcase
2022-09-13 08:46:16 +02:00
Paulo Telles
e90ef6bc70 change node image and moment package version (#161)
Co-authored-by: p.souza <p.souza@cognigy.com>
2022-09-07 13:20:39 +02:00
Dave Horton
a59f6097d7 bump version to 0.7.6 2022-08-26 20:06:04 +02:00
Dave Horton
887c6243e2 handle altLanguages set at the session level via config verb; fix azure stt race condition with final transcripts from stopped recognition 2022-08-25 22:43:38 +02:00
xquanluu
127432f2ec feat: play verb url support single or array list url (#158)
* feat: update time-series 0.11.12

* feat: support play verb url in plain text or array

* fix: review comment

Co-authored-by: Dave Horton <daveh@beachdognet.com>
2022-08-25 10:09:48 +02:00
Dave Horton
4f0439dad9 slight delay when releasing media after call answer, to allow A leg ACK transaction to complete on SBC 2022-08-24 14:25:14 +02:00
Dave Horton
9c188736f9 bugfix: enforce min bargein word count even when we get final transcript (#155) 2022-08-23 16:16:02 +02:00
xquanluu
a69dbb3d4f feat: update time-series 0.11.12 (#153) 2022-08-19 16:28:21 +02:00
Dave Horton
b2e21f06a8 update time-series 2022-08-19 10:00:16 +02:00
Dave Horton
a325bb554a bugfix #152: add key to fsUUIDs periodically 2022-08-19 09:38:40 +02:00
Dave Horton
aa5e3d9437 update undici 2022-08-18 23:25:25 +02:00
Dave Horton
6346954e7a session-level speech hints, strip trailing punctuation on continuous asr (#151) 2022-08-18 23:18:24 +02:00
xquanluu
5b6f7dd3ee feat: add alert for jambonz parsing falure (#148)
* feat: add alert for jambonz parsing falure

* fix: review comment

* fix: update time-series version
2022-08-16 12:39:07 +02:00
Dave Horton
7199db5edb minor performance improvements 2022-08-14 18:28:47 +02:00
Dave Horton
8644b858b3 bugfix: aws region was not being passed to aws tts or stt 2022-08-12 13:16:48 +02:00
Dave Horton
3d475217ca bugfix: when bargein is disabled, kill the background gather and do not restart it 2022-08-11 14:32:37 +02:00
Dave Horton
f580bc60f5 bugfix: regression from recent change to pass resources to tasks as an object 2022-08-11 14:32:08 +02:00
Dave Horton
1a4f8563f2 remove logging 2022-08-10 19:39:00 +02:00
Dave Horton
a021ca19a5 bugfix #146: query params were being dropped on http webhook requests 2022-08-10 19:31:59 +02:00
Dave Horton
2a4f8e3ff9 Simplify test (#145)
* feat: add create-call timeout test

* feat: single webhook-test-scaffold and basic auth callhook testcase

* cleanup

Co-authored-by: Quan Luu <quan.luuhoang8@gmail.com>
Co-authored-by: xquanluu <110280845+xquanluu@users.noreply.github.com>
2022-08-10 14:13:25 +02:00
Dave Horton
3298918322 Feature/siprec server (#143)
* fixes from testing

* modify Task#exec to take resources as an object rather than argument list

* pass 2 endpoints to Transcribe when invoked in a SipRec call session

* logging

* change siprec invite to sendrecv just so freeswitch does not try to reinvite (TODO: block outgoing media at rtpengine)

* Config: when enabling recording, block until siprec dialog is established

* missed play verb in commit 031c79d

* linting

* bugfix: get final transcript in siprec call
2022-08-09 15:23:55 +02:00
Dave Horton
f068aa5390 update Dockerfile for jambonz/webhook-test-scaffold 2022-08-09 10:52:58 +02:00
xquanluu
6e6ab56163 feat: add create-call timeout test (#142) 2022-08-09 10:18:30 +02:00
Dave Horton
91204955c9 Feature/siprec server (#140)
* initial support for siprec/agent assist

* call siprec middleware

* logger fix

* remove verbs that are not valid in a siprec call session
2022-08-05 10:29:13 +01:00
Dave Horton
bc3552dda7 bugfix: sending partial transcripts from gather was causing error 2022-08-03 12:00:38 +01:00
Dave Horton
d459be2942 bugfix: amd greeting stop 2022-08-01 16:04:36 +01:00
Dave Horton
1c5c76de61 fix prev commit 2022-08-01 15:12:35 +01:00
Dave Horton
cb6817449d minor cleanup 2022-08-01 15:10:23 +01:00
Dave Horton
ffa006225b fix amd timers 2022-08-01 12:20:12 +01:00
Joan
11d9a13ac7 Added spanish and catalan translations for VMD (#139)
* added vm detection in spanish

* added vm detection in catalan

Co-authored-by: Joan Salvatella <joan@bookline.io>
2022-07-28 10:31:59 +01:00
Dave Horton
21d5af367b update node image for Dockerfile 2022-07-27 18:01:15 +01:00
Dave Horton
2882fa2d0a Feature/vm detection (#137)
* initial changes for amd

* wip

* fix bug where transcripts were discarded

* a bit of refactoring, and adding support for avmd in config verb

* bug fixes
2022-07-27 17:46:52 +01:00
Dave Horton
a035b67e6c bugfix: hold music fetched when conference member removed from hold 2022-07-27 11:37:12 +01:00
Dave Horton
6979affb86 Feature/fast http client (#132)
* initial changes to use undici for http client and connection pooling

* use body.json() mixin

* logging

* add pipelining env var

* implement socket close
2022-07-18 15:32:03 +02:00
Dave Horton
bb9c3a8df0 createCall: return callId along with sid 2022-07-12 09:56:13 +02:00
Paulo Telles
92fa3c249c improve dockerfile to fix snyk security issues (#126)
Co-authored-by: p.souza <p.souza@cognigy.com>
2022-07-07 15:20:26 +02:00
Dave Horton
7f808c6107 listen: when passDtmf is true, send dtmf events down websocket connection as json test frames (#129) 2022-07-07 11:55:29 +02:00
Dave Horton
f95524863d update parse-url, improve Dockerfile 2022-07-06 19:16:31 +02:00
Dave Horton
aceaa5b7da bugfix: continuous asr - if ended by dtmf allow collection of final transcript 2022-06-28 10:10:31 -04:00
Dave Horton
7d57c85153 bugfix #121: Dial verb not ending when call no answer timeout exceeded 2022-06-24 10:50:29 -04:00
Dave Horton
9aa0df256d initial changes to support siprec recording (#120)
* initial changes to support siprec recording

* include additional params on SIP INFO to start recording

* add support for maniupulating recording via REST API

* fixes from testing pause/resume recording
2022-06-23 16:21:35 -04:00
Dave Horton
627c38899f Feature/continuous asr (#119)
* bugfix: background gather for speech-only should still kill audio on dtmf entry when dtmfBargein is true

* initial changes for continuous asr

* move properties under recognizer

* update drachtio-srf@4.5.1

* catch exception on destroy
2022-06-21 10:35:27 -04:00
Dave Horton
bdb40b3aa0 update to drachtio-fsmrf@3.0.1 2022-06-18 15:55:23 -04:00
Dave Horton
12ad7e556f added support for sip:request verb, used to send SIP INFO/NOTIFY etc during call (#116) 2022-06-15 13:31:32 -04:00
Dave Horton
05d6c8d467 linting 2022-06-14 08:24:44 -04:00
akirilyuk
5e9407ff4e add defaults to rest call payload (#115)
Co-authored-by: akirilyuk <a.kirilyuk@cognigy.com>
2022-06-14 08:20:55 -04:00
Dave Horton
e4fefe8f44 update to azure 1.22.0 2022-06-11 16:16:53 -04:00
Dave Horton
f7aac33af4 update deps 2022-06-11 11:23:17 -04:00
Dave Horton
dc1d8de396 updates to drachtio-srf@4.5.0 and drachtio-fsmrf@3.0.0 2022-06-11 11:06:03 -04:00
Dave Horton
5be5b6d05d bugfix: broken enqueue waitHook (#113) 2022-06-11 10:38:35 -04:00
84 changed files with 10527 additions and 4264 deletions

View File

@@ -1,16 +1,15 @@
name: CI
on:
push:
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 14
node-version: lts/*
- run: npm ci
- run: npm run jslint
- run: docker pull drachtio/sipp
@@ -20,3 +19,5 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: ${{ secrets.AWS_REGION }}
MICROSOFT_REGION: ${{ secrets.MICROSOFT_REGION }}
MICROSOFT_API_KEY: ${{ secrets.MICROSOFT_API_KEY }}

View File

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

3
.gitignore vendored
View File

@@ -40,4 +40,5 @@ examples/*
ecosystem.config.js
.vscode
test/credentials/*.json
run-tests.sh
run-tests.sh
run-coverage.sh

View File

@@ -1,10 +1,23 @@
FROM node:lts-slim
FROM --platform=linux/amd64 node:18.12.1-alpine3.16 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" ]

78
app.js
View File

@@ -15,12 +15,9 @@ const tracer = require('./tracer')(process.env.JAMBONES_OTEL_SERVICE_NAME || 'ja
const api = require('@opentelemetry/api');
srf.locals = {...srf.locals, otel: {tracer, api}};
const PORT = process.env.HTTP_PORT || 3000;
const opts = {
timestamp: () => {return `, "time": "${new Date().toISOString()}"`;},
level: process.env.JAMBONES_LOGLEVEL || 'info'
};
const logger = require('pino')(opts);
const opts = {level: process.env.JAMBONES_LOGLEVEL || 'info'};
const pino = require('pino');
const logger = pino(opts, pino.destination({sync: false}));
const {LifeCycleEvents, FS_UUID_SET_NAME} = require('./lib/utils/constants');
const installSrfLocals = require('./lib/utils/install-srf-locals');
installSrfLocals(srf, logger);
@@ -28,24 +25,15 @@ installSrfLocals(srf, logger);
const {
initLocals,
createRootSpan,
handleSipRec,
getAccountDetails,
normalizeNumbers,
retrieveApplication,
invokeWebCallback
} = require('./lib/middleware')(srf, logger);
// HTTP
const express = require('express');
const helmet = require('helmet');
const app = express();
Object.assign(app.locals, {
logger,
srf
});
const httpRoutes = require('./lib/http-routes');
const InboundCallSession = require('./lib/session/inbound-call-session');
const SipRecCallSession = require('./lib/session/siprec-call-session');
if (process.env.DRACHTIO_HOST) {
srf.connect({host: process.env.DRACHTIO_HOST, port: process.env.DRACHTIO_PORT, secret: process.env.DRACHTIO_SECRET });
@@ -68,31 +56,20 @@ if (process.env.NODE_ENV === 'test') {
srf.use('invite', [
initLocals,
createRootSpan,
handleSipRec,
getAccountDetails,
normalizeNumbers,
retrieveApplication,
invokeWebCallback
]);
srf.invite((req, res) => {
const session = new InboundCallSession(req, res);
srf.invite(async(req, res) => {
const isSipRec = !!req.locals.siprec;
const session = isSipRec ? new SipRecCallSession(req, res) : new InboundCallSession(req, res);
if (isSipRec) await session.answerSipRecCall();
session.exec();
});
// HTTP
app.use(helmet());
app.use(helmet.hidePoweredBy());
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use('/', httpRoutes);
app.use((err, req, res, next) => {
logger.error(err, 'burped error');
res.status(err.status || 500).json({msg: err.message});
});
const httpServer = app.listen(PORT);
logger.info(`listening for HTTP requests on port ${PORT}, serviceUrl is ${srf.locals.serviceUrl}`);
const sessionTracker = srf.locals.sessionTracker = require('./lib/session/session-tracker');
sessionTracker.on('idle', () => {
if (srf.locals.lifecycleEmitter.operationalState === LifeCycleEvents.ScaleIn) {
@@ -100,34 +77,57 @@ sessionTracker.on('idle', () => {
srf.locals.lifecycleEmitter.scaleIn();
}
});
const getCount = () => sessionTracker.count;
const healthCheck = require('@jambonz/http-health-check');
healthCheck({app, logger, path: '/', fn: getCount});
let httpServer;
const createHttpListener = require('./lib/utils/http-listener');
createHttpListener(logger, srf)
.then(({server, app}) => {
httpServer = server;
healthCheck({app, logger, path: '/', fn: getCount});
return {server, app};
})
.catch((err) => {
logger.error(err, 'Error creating http listener');
});
setInterval(() => {
srf.locals.stats.gauge('fs.sip.calls.count', sessionTracker.count);
}, 5000);
}, 20000);
const disconnect = () => {
return new Promise ((resolve) => {
httpServer.on('close', resolve);
httpServer.close();
httpServer?.on('close', resolve);
httpServer?.close();
srf.disconnect();
srf.locals.mediaservers.forEach((ms) => ms.disconnect());
});
};
process.on('SIGUSR2', handle);
process.on('SIGTERM', handle);
function handle(signal) {
const {removeFromSet} = srf.locals.dbHelpers;
srf.locals.disabled = true;
logger.info(`got signal ${signal}`);
const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-fs`;
logger.info(`got signal ${signal}, removing ${srf.locals.localSipAddress} from set ${setName}`);
removeFromSet(setName, srf.locals.localSipAddress);
if (setName && srf.locals.localSipAddress) {
logger.info(`got signal ${signal}, removing ${srf.locals.localSipAddress} from set ${setName}`);
removeFromSet(setName, srf.locals.localSipAddress);
}
removeFromSet(FS_UUID_SET_NAME, srf.locals.fsUUID);
srf.locals.disabled = true;
if (process.env.K8S) {
srf.locals.lifecycleEmitter.operationalState = LifeCycleEvents.ScaleIn;
}
if (getCount() === 0) {
logger.info('no calls in progress, exiting');
process.exit(0);
}
}
if (process.env.JAMBONZ_CLEANUP_INTERVAL_MINS) {

View 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"
]
}

View File

@@ -3,7 +3,7 @@ const makeTask = require('../../tasks/make_task');
const RestCallSession = require('../../session/rest-call-session');
const CallInfo = require('../../session/call-info');
const {CallDirection, CallStatus} = require('../../utils/constants');
const { v4: uuidv4 } = require('uuid');
const uuidv4 = require('uuid-random');
const SipError = require('drachtio-srf').SipError;
const sysError = require('./error');
const HttpRequestor = require('../../utils/http-requestor');
@@ -41,7 +41,8 @@ router.post('/', async(req, res) => {
'X-Jambonz-Routing': target.type,
'X-Jambonz-FS-UUID': srf.locals.fsUUID,
'X-Call-Sid': callSid,
'X-Account-Sid': accountSid
'X-Account-Sid': accountSid,
...(restDial.fromHost && {'X-Preferred-From-Host': restDial.fromHost})
};
switch (target.type) {
@@ -84,7 +85,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');
@@ -136,7 +136,7 @@ router.post('/', async(req, res) => {
}
else if (!app.notifier) {
logger.debug('creating null call status hook');
app.notifier = {request: () => {}};
app.notifier = {request: () => {}, close: () => {}};
}
/* now launch the outdial */
@@ -197,9 +197,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;

View File

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

View File

@@ -41,7 +41,7 @@ function retrieveCallSession(callSid, opts) {
router.post('/:callSid', async(req, res) => {
const logger = req.app.locals.logger;
const callSid = req.params.callSid;
logger.debug({body: req.body}, 'got upateCall request');
logger.debug({body: req.body}, 'got updateCall request');
try {
const cs = retrieveCallSession(callSid, req.body);
if (!cs) {

View File

@@ -1,5 +1,6 @@
const { v4: uuidv4 } = require('uuid');
const {CallDirection} = require('./utils/constants');
const uuidv4 = require('uuid-random');
const {CallDirection, AllowedSipRecVerbs} = require('./utils/constants');
const {parseSiprecPayload} = require('./utils/siprec-utils');
const CallInfo = require('./session/call-info');
const HttpRequestor = require('./utils/http-requestor');
const WsRequestor = require('./utils/ws-requestor');
@@ -18,16 +19,22 @@ module.exports = function(srf, logger) {
lookupAppByRealm,
lookupAppByTeamsTenant
} = srf.locals.dbHelpers;
const {
writeAlerts,
AlertType
} = srf.locals;
const {lookupAccountDetails} = dbUtils(logger, srf);
function initLocals(req, res, next) {
const callId = req.get('Call-ID');
logger.info({callId}, 'new incoming call');
if (!req.has('X-Account-Sid')) {
logger.info('getAccountDetails - rejecting call due to missing X-Account-Sid header');
return res.send(500);
}
const callSid = req.has('X-Retain-Call-Sid') ? req.get('X-Retain-Call-Sid') : uuidv4();
const account_sid = req.get('X-Account-Sid');
req.locals = {callSid, account_sid};
req.locals = {callSid, account_sid, callId};
if (req.has('X-Application-Sid')) {
const application_sid = req.get('X-Application-Sid');
logger.debug(`got application from X-Application-Sid header: ${application_sid}`);
@@ -40,7 +47,7 @@ module.exports = function(srf, logger) {
}
function createRootSpan(req, res, next) {
const {callSid, account_sid} = req.locals;
const {callId, callSid, account_sid} = req.locals;
const rootSpan = new RootSpan('incoming-call', req);
const traceId = rootSpan.traceId;
@@ -48,7 +55,7 @@ module.exports = function(srf, logger) {
...req.locals,
traceId,
logger: logger.child({
callId: req.get('Call-ID'),
callId,
callSid,
accountSid: account_sid,
callingNumber: req.callingNumber,
@@ -73,6 +80,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
*/
@@ -82,6 +118,7 @@ module.exports = function(srf, logger) {
const {span} = rootSpan.startChildSpan('lookupAccountDetails');
try {
req.locals.accountInfo = await lookupAccountDetails(account_sid);
req.locals.service_provider_sid = req.locals.accountInfo?.account?.service_provider_sid;
span.end();
if (!req.locals.accountInfo.account.is_active) {
logger.info(`Account is inactive or suspended ${account_sid}`);
@@ -101,7 +138,10 @@ module.exports = function(srf, logger) {
* Within the system, we deal with E.164 numbers _without_ the leading '+
*/
function normalizeNumbers(req, res, next) {
const logger = req.locals.logger;
const {logger, siprec} = req.locals;
if (siprec) return next();
Object.assign(req.locals, {
calledNumber: req.calledNumber,
callingNumber: req.callingNumber
@@ -122,8 +162,7 @@ module.exports = function(srf, logger) {
* Given the dialed DID/phone number, retrieve the application to invoke
*/
async function retrieveApplication(req, res, next) {
const logger = req.locals.logger;
const {accountInfo, account_sid, rootSpan} = req.locals;
const {logger, accountInfo, account_sid, rootSpan} = req.locals;
const {span} = rootSpan.startChildSpan('lookupApplication');
try {
let app;
@@ -186,29 +225,32 @@ module.exports = function(srf, logger) {
* create a requestor that we will use for all http requests we make during the call.
* also create a notifier for call status events (if not needed, its a no-op).
*/
/* allow for caching data - when caching treat retrieved data as immutable */
const app2 = process.env.JAMBONES_MYSQL_REFRESH_TTL ? JSON.parse(JSON.stringify(app)) : app;
if ('WS' === app.call_hook?.method ||
app.call_hook?.url.startsWith('ws://') || app.call_hook?.url.startsWith('wss://')) {
app.requestor = new WsRequestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret) ;
app.notifier = app.requestor;
app.call_hook.method = 'WS';
app2.requestor = new WsRequestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret) ;
app2.notifier = app.requestor;
app2.call_hook.method = 'WS';
}
else {
app.requestor = new HttpRequestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret);
if (app.call_status_hook) app.notifier = new HttpRequestor(logger, account_sid, app.call_status_hook,
app2.requestor = new HttpRequestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret);
if (app.call_status_hook) app2.notifier = new HttpRequestor(logger, account_sid, app.call_status_hook,
accountInfo.account.webhook_secret);
else app.notifier = {request: () => {}};
else app2.notifier = {request: () => {}};
}
req.locals.application = app;
const obj = Object.assign({}, app);
delete obj.requestor;
delete obj.notifier;
req.locals.application = app2;
// eslint-disable-next-line no-unused-vars
const {call_hook, call_status_hook, ...appInfo} = obj; // mask sensitive data like user/pass on webhook
logger.info({app: appInfo}, `retrieved application for incoming call to ${req.locals.calledNumber}`);
const {call_hook, call_status_hook, ...appInfo} = app; // mask sensitive data like user/pass on webhook
// eslint-disable-next-line no-unused-vars
const {requestor, notifier, ...loggable} = appInfo;
logger.info({app: loggable}, `retrieved application for incoming call to ${req.locals.calledNumber}`);
req.locals.callInfo = new CallInfo({
req,
app,
app: app2,
direction: CallDirection.Inbound,
traceId: rootSpan.traceId
});
@@ -225,17 +267,19 @@ module.exports = function(srf, logger) {
*/
async function invokeWebCallback(req, res, next) {
const logger = req.locals.logger;
const {rootSpan, application:app} = req.locals;
const {rootSpan, siprec, application:app} = req.locals;
let span;
try {
if (app.tasks) {
if (app.tasks && !process.env.JAMBONES_MYSQL_REFRESH_TTL) {
app.tasks = normalizeJambones(logger, app.tasks).map((tdata) => makeTask(logger, tdata));
if (0 === app.tasks.length) throw new Error('no application provided');
return next();
}
/* retrieve the application to execute for this inbound call */
const params = Object.assign(['POST', 'WS'].includes(app.call_hook.method) ? {sip: req.msg} : {},
req.locals.callInfo, {
req.locals.callInfo,
{service_provider_sid: req.locals.service_provider_sid},
{
defaults: {
synthesizer: {
vendor: app.speech_synthesis_vendor,
@@ -261,10 +305,28 @@ 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});
span?.end();
writeAlerts({
account_sid: req.locals.account_sid,
target_sid: req.locals.callSid,
alert_type: AlertType.INVALID_APP_PAYLOAD,
message: `${err?.message}`.trim()
}).catch((err) => this.logger.info({err}, 'Error generating alert for parsing application'));
logger.info({err}, `Error retrieving or parsing application: ${err?.message}`);
res.send(480, {headers: {'X-Reason': err?.message || 'unknown'}});
app.requestor.close();
@@ -274,6 +336,7 @@ module.exports = function(srf, logger) {
return {
initLocals,
createRootSpan,
handleSipRec,
getAccountDetails,
normalizeNumbers,
retrieveApplication,

View File

@@ -1,6 +1,6 @@
const {CallDirection, CallStatus} = require('../utils/constants');
const parseUri = require('drachtio-srf').parseUri;
const { v4: uuidv4 } = require('uuid');
const uuidv4 = require('uuid-random');
/**
* @classdesc Represents the common information for all calls
* that is provided in call status webhooks
@@ -11,6 +11,7 @@ class CallInfo {
let srf;
this.direction = opts.direction;
this.traceId = opts.traceId;
this.callTerminationBy = undefined;
if (opts.req) {
const u = opts.req.getParsedHeader('from');
const uri = parseUri(u.uri);
@@ -119,7 +120,7 @@ class CallInfo {
applicationSid: this.applicationSid,
fsSipAddress: this.localSipAddress
};
['parentCallSid', 'originatingSipIp', 'originatingSipTrunkName'].forEach((prop) => {
['parentCallSid', 'originatingSipIp', 'originatingSipTrunkName', 'callTerminationBy'].forEach((prop) => {
if (this[prop]) obj[prop] = this[prop];
});
if (typeof this.duration === 'number') obj.duration = this.duration;

View File

@@ -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,12 +73,20 @@ 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;
this.requestor.on('command', this._onCommand.bind(this));
this.requestor.on('connection-dropped', this._onWsConnectionDropped.bind(this));
this.requestor.on('handover', (newRequestor) => {
this.logger.info(`handover to new base url ${newRequestor.url}`);
this.application.requestor = newRequestor;
});
}
/**
@@ -85,6 +103,10 @@ class CallSession extends Emitter {
return this.callInfo.direction;
}
get applicationSid() {
return this.callInfo.applicationSid;
}
/**
* SIP call-id for the call
*/
@@ -211,6 +233,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 +263,204 @@ class CallSession extends Emitter {
return this.rootSpan?.getTracingPropagation();
}
get recordState() { return this._recordState; }
set globalSttHints({hints, hintsBoost}) {
this._globalSttHints = {hints, hintsBoost};
}
get hasGlobalSttHints() {
const {hints = []} = this._globalSttHints || {};
return hints.length > 0;
}
get globalSttHints() {
return this._globalSttHints;
}
set altLanguages(langs) {
this._globalAltLanguages = langs;
}
get hasAltLanguages() {
return Array.isArray(this._globalAltLanguages);
}
get altLanguages() {
return this._globalAltLanguages;
}
set globalSttPunctuation(punctuate) {
this._globalSttPunctuation = punctuate;
}
get globalSttPunctuation() {
return this._globalSttPunctuation;
}
hasGlobalSttPunctuation() {
return this._globalSttPunctuation !== undefined;
}
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 {
if (this.backgroundGatherTask) {
this.logger.info('CallSession:enableBotMode - bot mode currently enabled, ignoring request to start again');
return;
}
const t = normalizeJambones(this.logger, [gather]);
this.backgroundGatherTask = makeTask(this.logger, t[0]);
this._bargeInEnabled = true;
this.backgroundGatherTask
.once('dtmf', this._clearTasks.bind(this))
.once('vad', this._clearTasks.bind(this))
.once('transcription', this._clearTasks.bind(this))
.once('timeout', this._clearTasks.bind(this));
.once('dtmf', this._clearTasks.bind(this, this.backgroundGatherTask))
.once('vad', this._clearTasks.bind(this, this.backgroundGatherTask))
.once('transcription', this._clearTasks.bind(this, this.backgroundGatherTask))
.once('timeout', this._clearTasks.bind(this, this.backgroundGatherTask));
this.logger.info({gather}, 'CallSession:enableBotMode - starting background gather');
const resources = await this._evaluatePreconditions(this.backgroundGatherTask);
const {span, ctx} = this.rootSpan.startChildSpan(`background-gather:${this.backgroundGatherTask.summary}`);
@@ -254,7 +472,7 @@ class CallSession extends Emitter {
this.backgroundGatherTask && this.backgroundGatherTask.removeAllListeners();
this.backgroundGatherTask && this.backgroundGatherTask.span.end();
this.backgroundGatherTask = null;
if (autoEnable && !this.callGone && !this._stopping) {
if (autoEnable && !this.callGone && !this._stopping && this._bargeInEnabled) {
this.logger.info('CallSession:enableBotMode: restarting background gather');
setImmediate(() => this.enableBotMode(gather, true));
}
@@ -271,6 +489,7 @@ class CallSession extends Emitter {
}
}
disableBotMode() {
this._bargeInEnabled = false;
if (this.backgroundGatherTask) {
try {
this.backgroundGatherTask.removeAllListeners();
@@ -338,7 +557,11 @@ class CallSession extends Emitter {
return {
speech_credential_sid: credential.speech_credential_sid,
api_key: credential.api_key,
region: credential.region
region: credential.region,
use_custom_stt: credential.use_custom_stt,
custom_stt_endpoint: credential.custom_stt_endpoint,
use_custom_tts: credential.use_custom_tts,
custom_tts_endpoint: credential.custom_tts_endpoint
};
}
else if ('wellsaid' === vendor) {
@@ -373,13 +596,19 @@ class CallSession extends Emitter {
this.logger.info(`CallSession:exec starting task #${stackNum}:${taskNum}: ${task.name}`);
try {
const resources = await this._evaluatePreconditions(task);
let skip = false;
this.currentTask = task;
if (TaskName.Gather === task.name && this.isBotModeEnabled) {
const timeout = task.timeout;
this.logger.info(`CallSession:exec skipping #${stackNum}:${taskNum}: ${task.name}`);
this.backgroundGatherTask.updateTimeout(timeout);
if (this.backgroundGatherTask.updateTaskInProgress(task)) {
this.logger.info(`CallSession:exec skipping #${stackNum}:${taskNum}: ${task.name}`);
skip = true;
}
else {
this.logger.info('CallSession:exec disabling bot mode to start gather with new options');
this.disableBotMode();
}
}
else {
if (!skip) {
const {span, ctx} = this.rootSpan.startChildSpan(`verb:${task.summary}`);
task.span = span;
task.ctx = ctx;
@@ -427,6 +656,7 @@ class CallSession extends Emitter {
this._onTasksDone();
this._clearResources();
if (!this.isConfirmCallSession && !this.isSmsCallSession) sessionTracker.remove(this.callSid);
}
@@ -724,6 +954,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 +974,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++;
@@ -752,14 +999,32 @@ class CallSession extends Emitter {
}
}
kill() {
kill(onBackgroundGatherBargein = false) {
if (this.isConfirmCallSession) this.logger.debug('CallSession:kill (ConfirmSession)');
else this.logger.info('CallSession:kill');
if (this.currentTask) {
this.currentTask.kill(this);
this.currentTask = null;
}
this.tasks = [];
if (onBackgroundGatherBargein) {
/* search for a config with bargein disabled */
while (this.tasks.length) {
const t = this.tasks[0];
if (t.name === TaskName.Config && t.bargeIn?.enable === false) {
/* found it, clear to that point and remove the disable
because we likely already received a partial transcription
and we don't want to kill the background gather before we
get the full transcription.
*/
delete t.bargeIn.enable;
this._bargeInEnabled = false;
this.logger.info('CallSession:kill - found bargein disabled in the stack, clearing to that point');
break;
}
this.tasks.shift();
}
}
else this.tasks = [];
this.taskIdx = 0;
}
@@ -908,21 +1173,34 @@ class CallSession extends Emitter {
* @param {Task} task - task to be executed
*/
async _evalEndpointPrecondition(task) {
this.logger.debug('CallSession:_evalEndpointPrecondition');
if (this.callGone) new Error(`${BADPRECONDITIONS}: call gone`);
if (this.ep) {
if (task.earlyMedia === true || this.dlg) return this.ep;
const resources = {ep: this.ep};
if (task.earlyMedia === true || this.dlg) {
return {
...resources,
...(this.isSipRecCallSession && {ep2: this.ep2})
};
}
// we are going from an early media connection to answer
await this.propagateAnswer();
return this.ep;
return {
...resources,
...(this.isSipRecCallSession && {ep2: this.ep2})
};
}
// need to allocate an endpoint
try {
if (!this.ms) this.ms = this.getMS();
const ep = await this.ms.createEndpoint({remoteSdp: this.req.body});
const ep = await this.ms.createEndpoint({
headers: {
'X-Jambones-Call-ID': this.callId,
},
remoteSdp: this.req.body
});
//ep.cs = this;
this.ep = ep;
ep.set({
@@ -939,7 +1217,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,10 +1226,11 @@ class CallSession extends Emitter {
// outbound call TODO
}
return ep;
return {ep};
} catch (err) {
if (err === CALLER_CANCELLED_ERR_MSG) {
this.logger.error(err, 'caller canceled quickly before we could respond, ending call');
this.callInfo.callTerminationBy = 'caller';
this._notifyCallStatusChange({
callStatus: CallStatus.NoAnswer,
sipStatus: 487,
@@ -973,7 +1252,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 +1291,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;
@@ -1029,6 +1308,7 @@ class CallSession extends Emitter {
}
this.tmpFiles.clear();
this.requestor && this.requestor.close();
this.notifier && this.notifier.close();
this.rootSpan && this.rootSpan.end();
}
@@ -1066,7 +1346,8 @@ class CallSession extends Emitter {
this.dlg = await this.srf.createUAS(this.req, this.res, {
headers: {
'X-Trace-ID': this.req.locals.traceId,
'X-Call-Sid': this.req.locals.callSid
'X-Call-Sid': this.req.locals.callSid,
...(this.applicationSid && {'X-Application-Sid': this.applicationSid})
},
localSdp: this.ep.local.sdp
});
@@ -1076,6 +1357,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));
@@ -1217,7 +1501,8 @@ class CallSession extends Emitter {
headers: {
'Refer-To': referTo,
'Referred-By': `sip:${this.srf.locals.localSipAddress}`,
'X-Retain-Call-Sid': this.callSid
'X-Retain-Call-Sid': this.callSid,
'X-Account-Sid': this.accountSid
}
});
if ([200, 202].includes(res.status)) {
@@ -1255,10 +1540,11 @@ class CallSession extends Emitter {
dlg.connected = false;
dlg.destroy = origDestroy;
const duration = moment().diff(this.dlg.connectTime, 'seconds');
this.callInfo.callTerminationBy = 'jambonz';
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
this.logger.debug('CallSession: call terminated by jambones');
this.logger.debug('CallSession: call terminated by jambonz');
this.rootSpan.setAttributes({'call.termination': 'hangup by jambonz'});
origDestroy();
origDestroy().catch((err) => this.logger.info({err}, 'CallSession - error destroying dialog'));
if (this.wakeupResolver) {
this.wakeupResolver({reason: 'session ended'});
this.wakeupResolver = null;
@@ -1305,7 +1591,7 @@ class CallSession extends Emitter {
* @param {number} sipStatus - current sip status
* @param {number} [duration] - duration of a completed call, in seconds
*/
_notifyCallStatusChange({callStatus, sipStatus, sipReason, duration}) {
async _notifyCallStatusChange({callStatus, sipStatus, sipReason, duration}) {
if (this.callMoved) return;
/* race condition: we hang up at the same time as the caller */
@@ -1325,7 +1611,7 @@ class CallSession extends Emitter {
try {
const b3 = this.b3;
const httpHeaders = b3 && {b3};
this.notifier.request('call:status', this.call_status_hook, this.callInfo.toJSON(), httpHeaders);
await this.notifier.request('call:status', this.call_status_hook, this.callInfo.toJSON(), httpHeaders);
span.end();
} catch (err) {
span.end();
@@ -1346,11 +1632,12 @@ class CallSession extends Emitter {
});
}
_clearTasks(evt) {
if (this.requestor instanceof WsRequestor) {
_clearTasks(backgroundGather, evt) {
if (this.requestor instanceof WsRequestor && !backgroundGather.cleared) {
this.logger.info({evt}, 'CallSession:_clearTasks on event from background gather');
try {
this.kill();
backgroundGather.cleared = true;
this.kill(true);
} catch (err) {}
}
}

View File

@@ -34,6 +34,7 @@ class InboundCallSession extends CallSession {
_onCancel() {
this.rootSpan.setAttributes({'call.termination': 'caller abandoned'});
this.callInfo.callTerminationBy = 'caller';
this._notifyCallStatusChange({
callStatus: CallStatus.NoAnswer,
sipStatus: 487,
@@ -69,6 +70,7 @@ class InboundCallSession extends CallSession {
assert(this.dlg.connectTime);
const duration = moment().diff(this.dlg.connectTime, 'seconds');
this.rootSpan.setAttributes({'call.termination': 'hangup by caller'});
this.callInfo.callTerminationBy = 'caller';
this.emit('callStatusChange', {
callStatus: CallStatus.Completed,
duration

View File

@@ -44,6 +44,7 @@ class RestCallSession extends CallSession {
* This is invoked when the called party hangs up, in order to calculate the call duration.
*/
_callerHungup() {
this.callInfo.callTerminationBy = 'caller';
const duration = moment().diff(this.dlg.connectTime, 'seconds');
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
this.logger.debug('RestCallSession: called party hung up');

View File

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

View File

@@ -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');
}
/**
@@ -541,6 +541,9 @@ class Conference extends Task {
}
this.logger.debug(`Conference:_playHook: executing ${tasks.length} tasks`);
/* we might have been killed while off fetching waitHook */
if (this.killed) return [];
if (tasks.length > 0) {
this._playSession = new ConfirmCallSession({
logger: this.logger,
@@ -551,7 +554,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;

View File

@@ -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 || this.data.amd) ?
TaskPreconditions.Endpoint :
TaskPreconditions.None;
}
get name() { return TaskName.Config; }
@@ -49,12 +50,26 @@ 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) {
async exec(cs, {ep} = {}) {
await super.exec(cs);
if (this.data.amd) {
this.startAmd = cs.startAmd;
this.stopAmd = cs.stopAmd;
this.on('amd', this._onAmdEvent.bind(this, cs));
try {
this.ep = ep;
this.startAmd(cs, ep, this, this.data.amd);
} catch (err) {
this.logger.info({err}, 'Config:exec - Error calling startAmd');
}
}
if (this.hasSynthesizer) {
cs.speechSynthesisVendor = this.synthesizer.vendor !== 'default'
? this.synthesizer.vendor
@@ -74,10 +89,32 @@ 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;
}
if (Array.isArray(this.recognizer.hints)) {
const obj = {hints: this.recognizer.hints};
if (typeof this.recognizer.hintsBoost === 'number') {
obj.hintsBoost = this.recognizer.hintsBoost;
}
cs.globalSttHints = obj;
}
if (Array.isArray(this.recognizer.altLanguages)) {
this.logger.info({altLanguages: this.recognizer.altLanguages}, 'Config: updated altLanguages');
cs.altLanguages = this.recognizer.altLanguages;
}
if ('punctuation' in this.recognizer) {
cs.globalSttPunctuation = this.recognizer.punctuation;
}
this.logger.info({
recognizer: this.recognizer,
isContinuousAsr: cs.isContinuousAsr
}, 'Config: updated recognizer');
}
if ('enable' in this.bargeIn) {
if (this.gatherOpts) {
if (this.bargeIn.enable === true && this.gatherOpts) {
this.gatherOpts.recognizer = this.hasRecognizer ?
this.recognizer :
{
@@ -87,15 +124,33 @@ class TaskConfig extends Task {
this.logger.info({opts: this.gatherOpts}, 'Config: enabling bargeIn');
cs.enableBotMode(this.gatherOpts, this.autoEnable);
}
else {
else if (this.bargeIn.enable === false) {
this.logger.info('Config: disabling bargeIn');
cs.disableBotMode();
}
}
if (this.record.action) {
try {
await cs.notifyRecordOptions(this.record);
} catch (err) {
this.logger.info({err}, 'Config: error starting recording');
}
}
}
async kill(cs) {
super.kill(cs);
if (this.ep && this.stopAmd) this.stopAmd(this.ep, this);
}
_onAmdEvent(cs, evt) {
this.logger.info({evt}, 'Config:_onAmdEvent');
const {actionHook} = this.data.amd;
this.performHook(cs, actionHook, evt)
.catch((err) => {
this.logger.error({err}, 'Config:_onAmdEvent - error calling actionHook');
});
}
}

View File

@@ -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}`;

View File

@@ -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');
@@ -357,9 +376,8 @@ class TaskDial extends Task {
}
async _initializeInbound(cs) {
const ep = await cs._evalEndpointPrecondition(this);
const {ep} = await cs._evalEndpointPrecondition(this);
this.epOther = ep;
debug(`Dial:__initializeInbound allocated ep for incoming call: ${ep.uuid}`);
/* send outbound legs back to the same SBC (to support static IP feature) */
if (!this.proxy) this.proxy = `${cs.req.source_address}:${cs.req.source_port}`;
@@ -404,6 +422,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 +589,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,11 +630,18 @@ class TaskDial extends Task {
if (this.parentDtmfCollector) this._installDtmfDetection(cs, cs.dlg);
if (this.childDtmfCollector) this._installDtmfDetection(cs, this.dlg);
if (this.transcribeTask) this.transcribeTask.exec(cs, this.epOther, this.ep);
if (this.listenTask) this.listenTask.exec(cs, this.epOther);
if (this.transcribeTask) this.transcribeTask.exec(cs, {ep2: this.epOther, ep:this.ep});
if (this.listenTask) this.listenTask.exec(cs, {ep: this.epOther});
if (this.startAmd) {
try {
this.startAmd(cs, this.ep, this, this.data.amd);
} catch (err) {
this.logger.info({err}, 'Dial:_selectSingleDial - Error calling startAmd');
}
}
/* if we can release the media back to the SBC, do so now */
if (this.canReleaseMedia) this._releaseMedia(cs, sd);
if (this.canReleaseMedia) setTimeout(this._releaseMedia.bind(this, cs, sd), 200);
}
_bridgeEarlyMedia(sd) {
@@ -654,6 +685,15 @@ class TaskDial extends Task {
this.logger.info({sdp}, 'Dial:handleReinviteAfterMediaReleased - sent reinvite to B leg');
res.send(200, {body: sdp});
}
_onAmdEvent(cs, evt) {
this.logger.info({evt}, 'Dial:_onAmdEvent');
const {actionHook} = this.data.amd;
this.performHook(cs, actionHook, evt)
.catch((err) => {
this.logger.error({err}, 'Dial:_onAmdEvent - error calling actionHook');
});
}
}
module.exports = TaskDial;

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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;

View File

@@ -11,6 +11,19 @@ const makeTask = require('./make_task');
const assert = require('assert');
//const GATHER_STABILITY_THRESHOLD = Number(process.env.JAMBONZ_GATHER_STABILITY_THRESHOLD || 0.7);
const compileTranscripts = (logger, evt, arr) => {
//logger.debug({arr, evt}, 'compile transcripts');
if (!Array.isArray(arr) || arr.length === 0) return;
let t = '';
for (const a of arr) {
//logger.debug(`adding ${a.alternatives[0].transcript}`);
t += ` ${a.alternatives[0].transcript}`;
}
t += ` ${evt.alternatives[0].transcript}`;
evt.alternatives[0].transcript = t.trim();
//logger.debug(`compiled transcript: ${evt.alternatives[0].transcript}`);
};
class TaskGather extends Task {
constructor(logger, opts, parentTask) {
super(logger, opts);
@@ -23,11 +36,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 +62,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};
@@ -64,6 +82,12 @@ class TaskGather extends Task {
this.requestSnr = recognizer.requestSnr || false;
this.initialSpeechTimeoutMs = recognizer.initialSpeechTimeoutMs || 0;
this.azureServiceEndpoint = recognizer.azureServiceEndpoint;
this.azureSttEndpointId = recognizer.azureSttEndpointId;
this.azureAudioLogging = recognizer.audioLogging;
}
else {
this.hints = [];
this.altLanguages = [];
}
this.digitBuffer = '';
@@ -77,6 +101,9 @@ class TaskGather extends Task {
}
if (!this.sayTask && !this.playTask) this.listenDuringPrompt = false;
/* buffer speech for continuous asr */
this._bufferedTranscripts = [];
this.parentTask = parentTask;
}
@@ -104,11 +131,43 @@ 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 (cs.hasGlobalSttHints) {
const {hints, hintsBoost} = cs.globalSttHints;
this.hints = this.hints.concat(hints);
if (!this.hintsBoost && hintsBoost) this.hintsBoost = hintsBoost;
this.logger.debug({hints: this.hints, hintsBoost: this.hintsBoost},
'Gather:exec - applying global sttHints');
}
if (cs.hasAltLanguages) {
this.altLanguages = this.altLanguages.concat(cs.altLanguages);
this.logger.debug({altLanguages: this.altLanguages},
'Gather:exec - applying altLanguages');
}
if (cs.hasGlobalSttPunctuation) {
this.punctuation = cs.globalSttPunctuation;
}
if (!this.isContinuousAsr && cs.isContinuousAsr) {
this.isContinuousAsr = true;
this.asrTimeout = cs.asrTimeout * 1000;
this.asrDtmfTerminationDigit = cs.asrDtmfTerminationDigit;
this.logger.debug({
asrTimeout: this.asrTimeout,
asrDtmfTerminationDigit: this.asrDtmfTerminationDigit
}, 'Gather:exec - enabling continuous ASR since it is turned on for the session');
}
if (process.env.JAMBONZ_GATHER_EARLY_HINTS_MATCH &&
!this.isContinuousAsr &&
this.hints.length > 0 && this.hints.length <= 10) {
this.earlyHintsMatch = true;
this.interim = true;
this.logger.debug('Gather:exec - early hints match enabled');
}
this.ep = ep;
if ('default' === this.vendor || !this.vendor) this.vendor = cs.speechRecognizerVendor;
if ('default' === this.language || !this.language) this.language = cs.speechRecognizerLanguage;
@@ -127,9 +186,15 @@ class TaskGather extends Task {
const startListening = (cs, ep) => {
this._startTimer();
// dont start asr timer until we have a transcription
//if (this.isContinuousAsr && 0 === this.timeout) this._startAsrTimer();
if (this.input.includes('speech') && !this.listenDuringPrompt) {
this._initSpeech(cs, ep)
.then(() => {
if (this.killed) {
this.logger.info('Gather:exec - task was quickly killed so do not transcribe');
return;
}
this._startTranscribing(ep);
return updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid);
})
@@ -142,7 +207,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 +219,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');
@@ -162,7 +227,13 @@ class TaskGather extends Task {
if (!this.killed) startListening(cs, ep);
});
}
else startListening(cs, ep);
else {
if (this.killed) {
this.logger.info('Gather:exec - task was immediately killed so do not transcribe');
return;
}
startListening(cs, ep);
}
if (this.input.includes('speech') && this.listenDuringPrompt) {
await this._initSpeech(cs, ep);
@@ -171,7 +242,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));
}
@@ -199,22 +270,30 @@ class TaskGather extends Task {
this._resolve('killed');
}
updateTimeout(timeout) {
this.logger.info(`TaskGather:updateTimeout - updating timeout to ${timeout}`);
updateTaskInProgress(opts) {
if (!this.needsStt && opts.input.includes('speech')) {
this.logger.info('TaskGather:updateTaskInProgress - adding speech to a background gather');
return false; // this needs be handled by killing the background gather and starting a new one
}
const {timeout} = opts;
this.timeout = timeout;
this._startTimer();
return true;
}
_onDtmf(cs, ep, evt) {
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 +301,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 +327,7 @@ class TaskGather extends Task {
}
if ('google' === this.vendor) {
this.bugname = 'google_transcribe';
if (this.sttCredentials) opts.GOOGLE_APPLICATION_CREDENTIALS = JSON.stringify(this.sttCredentials.credentials);
[
['enhancedModel', 'GOOGLE_SPEECH_USE_ENHANCED'],
@@ -252,14 +339,16 @@ class TaskGather extends Task {
['diarization', 'GOOGLE_SPEECH_PROFANITY_FILTER']
].forEach((arr) => {
if (this[arr[0]]) opts[arr[1]] = true;
else if (this[arr[0]] === false) opts[arr[1]] = false;
});
if (this.hints.length > 1) {
if (this.hints.length > 0) {
opts.GOOGLE_SPEECH_HINTS = this.hints.join(',');
if (typeof this.hintsBoost === 'number') {
opts.GOOGLE_SPEECH_HINTS_BOOST = this.hintsBoost;
}
}
if (this.altLanguages.length > 1) opts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
if (this.altLanguages.length > 0) opts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
else opts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = '';
if ('unspecified' !== this.interactionType) {
opts.GOOGLE_SPEECH_METADATA_INTERACTION_TYPE = this.interactionType;
}
@@ -276,6 +365,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_transcribe';
if (this.vocabularyName) opts.AWS_VOCABULARY_NAME = this.vocabularyName;
if (this.vocabularyFilterName) {
opts.AWS_VOCABULARY_NAME = this.vocabularyFilterName;
@@ -292,18 +382,31 @@ class TaskGather extends Task {
ep.addCustomEventListener(AwsTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep));
}
else if ('microsoft' === this.vendor) {
this.bugname = 'azure_transcribe';
if (this.sttCredentials) {
const {api_key, region, use_custom_stt, custom_stt_endpoint} = this.sttCredentials;
Object.assign(opts, {
'AZURE_SUBSCRIPTION_KEY': this.sttCredentials.api_key,
'AZURE_REGION': this.sttCredentials.region
'AZURE_SUBSCRIPTION_KEY': api_key,
'AZURE_REGION': region
});
if (this.azureSttEndpointId) {
Object.assign(opts, {'AZURE_SERVICE_ENDPOINT_ID': this.azureSttEndpointId});
}
else if (use_custom_stt && custom_stt_endpoint) {
Object.assign(opts, {'AZURE_SERVICE_ENDPOINT_ID': custom_stt_endpoint});
}
}
if (this.hints && this.hints.length > 1) {
if (this.hints && this.hints.length > 0) {
opts.AZURE_SPEECH_HINTS = this.hints.map((h) => h.trim()).join(',');
}
if (this.altLanguages && this.altLanguages.length > 0) {
opts.AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
}
else {
opts.AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = '';
}
if (this.azureAudioLogging) opts.AZURE_AUDIO_LOGGING = 1;
if (this.requestSnr) opts.AZURE_REQUEST_SNR = 1;
if (this.profanityOption && this.profanityOption !== 'raw') opts.AZURE_PROFANITY_OPTION = this.profanityOption;
if (this.azureServiceEndpoint) opts.AZURE_SERVICE_ENDPOINT = this.azureServiceEndpoint;
@@ -323,12 +426,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,12 +448,7 @@ 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._clearTimer();
this._timeoutTimer = setTimeout(() => {
this._resolve(this.digitBuffer.length >= this.minDigits ? 'dtmf-num-digits' : 'timeout');
}, this.timeout);
@@ -361,6 +461,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) {
@@ -383,7 +512,12 @@ 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');
const finished = fsEvent.getHeader('transcription-session-finished');
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';
@@ -414,12 +548,66 @@ class TaskGather extends Task {
};
}
}
if (this.earlyHintsMatch && evt.is_final === false) {
const transcript = evt.alternatives[0].transcript?.toLowerCase();
if (this.hints.find((h) => h.toLowerCase() === transcript)) {
this.logger.debug({evt}, 'Gather:_onTranscription: early hint match');
this._resolve('speech', evt);
return;
}
}
/* count words for bargein feature */
const words = evt.alternatives[0].transcript.split(' ').length;
const bufferedWords = this._bufferedTranscripts.reduce((count, e) => {
return count + e.alternatives[0].transcript.split(' ').length;
}, 0);
if (evt.is_final) {
if (evt.alternatives[0].transcript === '' && !this.callSession.callGone && !this.killed) {
this.logger.info({evt}, 'TaskGather:_onTranscription - got empty transcript, listen again');
if ('microsoft' === this.vendor && finished === 'true') {
this.logger.debug({evt}, 'TaskGather:_onTranscription - got empty transcript from old gather, disregarding');
}
else {
this.logger.info({evt}, 'TaskGather:_onTranscription - got empty transcript, listen again');
this._startTranscribing(ep);
}
return;
}
if (this.isContinuousAsr) {
/* append the transcript and start listening again for asrTimeout */
const t = evt.alternatives[0].transcript;
if (t) {
/* remove trailing punctuation */
if (/[,;:\.!\?]$/.test(t)) {
this.logger.debug('TaskGather:_onTranscription - removing trailing punctuation');
evt.alternatives[0].transcript = t.slice(0, -1);
}
else this.logger.debug({t}, 'TaskGather:_onTranscription - no trailing punctuation');
}
this.logger.info({evt}, 'TaskGather:_onTranscription - got transcript during continous asr');
this._bufferedTranscripts.push(evt);
this._clearTimer();
if (this._finalAsrTimer) {
this._clearFinalAsrTimer();
return this._resolve(this._bufferedTranscripts.length > 0 ? 'speech' : 'timeout');
}
this._startAsrTimer();
return this._startTranscribing(ep);
}
this._resolve('speech', evt);
else {
if (this.bargein && (words + bufferedWords) < this.minBargeinWordCount) {
this.logger.debug({evt, words, bufferedWords},
'TaskGather:_onTranscription - final transcript but < min barge words');
this._bufferedTranscripts.push(evt);
this._startTranscribing(ep);
return;
}
else {
this._resolve('speech', evt);
}
}
}
else {
/* google has a measure of stability:
@@ -427,9 +615,7 @@ class TaskGather extends Task {
others do not.
*/
//const isStableEnough = typeof evt.stability === 'undefined' || evt.stability > GATHER_STABILITY_THRESHOLD;
if (this.bargein && /* isStableEnough && */
evt.alternatives[0].transcript.split(' ').length >= this.minBargeinWordCount) {
if (this.bargein && (words + bufferedWords) >= this.minBargeinWordCount) {
if (!this.playComplete) {
this.logger.debug({transcript: evt.alternatives[0].transcript}, 'killing audio due to speech');
this.emit('vad');
@@ -439,7 +625,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));
}
}
@@ -450,7 +636,7 @@ class TaskGather extends Task {
this._killAudio(cs);
}
if (!this.resolved && !this.killed) {
if (!this.resolved && !this.killed && !this._bufferedTranscripts.length) {
this._startTranscribing(ep);
}
}
@@ -463,10 +649,17 @@ class TaskGather extends Task {
}
}
_onNoSpeechDetected(cs, ep) {
_onNoSpeechDetected(cs, ep, evt, fsEvent) {
if (!this.callSession.callGone && !this.killed) {
this.logger.debug('TaskGather:_onNoSpeechDetected - listen again');
return this._startTranscribing(ep);
const finished = fsEvent.getHeader('transcription-session-finished');
if (this.vendor === 'microsoft' && finished === 'true') {
this.logger.debug('TaskGather:_onNoSpeechDetected for old gather, ignoring');
}
else {
this.logger.debug('TaskGather:_onNoSpeechDetected - listen again');
this._startTranscribing(ep);
}
return;
}
}
@@ -476,15 +669,26 @@ 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');
}
else if (!this.isContinuousAsr && reason.startsWith('speech') && this._bufferedTranscripts.length) {
compileTranscripts(this.logger, evt, this._bufferedTranscripts);
this.logger.debug({evt}, 'TaskGather:resolve buffered results');
}
this.span.setAttributes({'stt.resolve': reason, 'stt.result': JSON.stringify(evt)});
if (this.ep && this.ep.connected) {
if (this.needsStt && this.ep && this.ep.connected) {
this.ep.stopTranscription({vendor: this.vendor})
.catch((err) => this.logger.error({err}, 'Error stopping transcription'));
}
this._clearTimer();
if (this.callSession && this.callSession.callGone) {
this.logger.debug('TaskGather:_resolve - call is gone, not invoking web callback');
this.notifyTaskDone();

View File

@@ -14,10 +14,11 @@ class TaskHangup extends Task {
/**
* Hangup the call
*/
async exec(cs, dlg) {
async exec(cs, {dlg}) {
await super.exec(cs);
try {
await dlg.destroy({headers: this.headers});
cs._callReleased();
} catch (err) {
this.logger.error(err, 'TaskHangup:exec - Error hanging up call');
}

View File

@@ -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();
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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);

View File

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

View File

@@ -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();

View File

@@ -7,6 +7,8 @@ class TaskPlay extends Task {
this.preconditions = TaskPreconditions.Endpoint;
this.url = this.data.url;
this.seekOffset = this.data.seekOffset || -1;
this.timeoutSecs = this.data.timeoutSecs || -1;
this.loop = this.data.loop || 1;
this.earlyMedia = this.data.earlyMedia === true;
}
@@ -17,16 +19,27 @@ 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 {
while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep.connected) {
if (cs.isInConference) {
const {memberId, confName, confUuid} = cs;
await this.playToConfMember(this.ep, memberId, confName, confUuid, this.url);
if (Array.isArray(this.url)) {
for (const playUrl of this.url) {
await this.playToConfMember(this.ep, memberId, confName, confUuid, playUrl);
}
} else {
await this.playToConfMember(this.ep, memberId, confName, confUuid, this.url);
}
} else {
const file = (this.timeoutSecs >= 0 || this.seekOffset >= 0) ?
{file: this.url, seekOffset: this.seekOffset, timeoutSecs: this.timeoutSecs} : this.url;
const result = await ep.play(file);
await this.performAction(Object.assign(result, {reason: 'playCompleted'}),
!(this.parentTask || cs.isConfirmCallSession));
}
else await ep.play(this.url);
}
} catch (err) {
this.logger.info(err, `TaskPlay:exec - error playing ${this.url}`);

View File

@@ -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();

View File

@@ -11,6 +11,7 @@ class TaskRestDial extends Task {
super(logger, opts);
this.from = this.data.from;
this.fromHost = this.data.fromHost;
this.to = this.data.to;
this.call_hook = this.data.call_hook;
this.timeout = this.data.timeout || 60;
@@ -50,7 +51,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)));

View File

@@ -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 {

View File

@@ -1,12 +1,107 @@
const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants');
const breakLengthyTextIfNeeded = (logger, text) => {
const chunkSize = 1000;
if (text.length <= chunkSize) return [text];
const result = [];
const isSSML = text.startsWith('<speak>');
let startPos = 0;
let charPos = isSSML ? 7 : 0; // skip <speak>
let tag;
//logger.debug({isSSML}, `breakLengthyTextIfNeeded: handling text of length ${text.length}`);
while (startPos + charPos < text.length) {
if (isSSML && !tag && text[startPos + charPos] === '<') {
const tagStartPos = ++charPos;
while (startPos + charPos < text.length) {
if (text[startPos + charPos] === '>') {
if (text[startPos + charPos - 1] === '\\') tag = null;
else if (!tag) tag = text.substring(startPos + tagStartPos, startPos + charPos - 1);
break;
}
if (!tag) {
const c = text[startPos + charPos];
if (c === ' ') {
tag = text.substring(startPos + tagStartPos, startPos + charPos);
//logger.debug(`breakLengthyTextIfNeeded: enter tag ${tag} (space)`);
break;
}
}
charPos++;
}
if (tag) {
//search for end of tag
//logger.debug(`breakLengthyTextIfNeeded: searching forward for </${tag}>`);
const e1 = text.indexOf(`</${tag}>`, startPos + charPos);
const e2 = text.indexOf('/>', startPos + charPos);
const tagEndPos = e1 === -1 ? e2 : e2 === -1 ? e1 : Math.min(e1, e2);
if (tagEndPos === -1) {
//logger.debug(`breakLengthyTextIfNeeded: exit tag ${tag} not found, exiting`);
} else {
//logger.debug(`breakLengthyTextIfNeeded: exit tag ${tag} found at ${tagEndPos}`);
charPos = tagEndPos + 1;
}
tag = null;
}
continue;
}
if (charPos < chunkSize) {
charPos++;
continue;
}
// start looking for a good break point
let chunkIt = false;
const a = text[startPos + charPos];
const b = text[startPos + charPos + 1];
if (/[\.!\?]/.test(a) && /\s/.test(b)) {
//logger.debug('breakLengthyTextIfNeeded: breaking at sentence end');
chunkIt = true;
}
if (chunkIt) {
charPos++;
const chunk = text.substr(startPos, charPos);
if (isSSML) {
result.push(0 === startPos ? `${chunk}</speak>` : `<speak>${chunk}</speak>`);
}
else result.push(chunk);
charPos = 0;
startPos += chunk.length;
//logger.debug({chunk: result[result.length - 1]},
// `breakLengthyTextIfNeeded: chunked; new starting pos ${startPos}`);
}
else charPos++;
}
// final chunk
if (startPos < text.length) {
const chunk = text.substr(startPos);
if (isSSML) {
result.push(0 === startPos ? `${chunk}</speak>` : `<speak>${chunk}`);
}
else result.push(chunk);
//logger.debug({chunk: result[result.length - 1]},
// `breakLengthyTextIfNeeded: final chunk; starting pos ${startPos} length ${chunk.length}`);
}
return result;
};
class TaskSay extends Task {
constructor(logger, opts, parentTask) {
super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint;
this.text = Array.isArray(this.data.text) ? this.data.text : [this.data.text];
this.text = (Array.isArray(this.data.text) ? this.data.text : [this.data.text])
.map((t) => breakLengthyTextIfNeeded(this.logger, t))
.flat();
this.loop = this.data.loop || 1;
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
this.synthesizer = this.data.synthesizer || {};
@@ -22,7 +117,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;
@@ -69,6 +164,10 @@ class TaskSay extends Task {
'tts.voice': voice
});
try {
if (vendor === 'microsoft' && this.synthesizer.azureServiceEndpoint) {
credentials.use_custom_tts = true;
credentials.custom_tts_endpoint = this.synthesizer.azureServiceEndpoint;
}
const {filePath, servedFromCache} = await synthAudio(stats, {
text,
vendor,

View File

@@ -36,6 +36,7 @@ class TaskSipRefer extends Task {
method: 'REFER',
headers: {
...this.headers,
...(this.referToIsUri && {'X-Refer-To-Leave-Untouched': true}),
'Refer-To': referTo,
'Referred-By': referredBy
}
@@ -46,7 +47,17 @@ class TaskSipRefer extends Task {
/* if we fail, fall through to next verb. If success, we should get BYE from far end */
if (this.referStatus === 202) {
this._notifyTimer = setTimeout(() => {
this.logger.info('TaskSipRefer:exec - no NOTIFY received in 15 secs, exiting');
this.performAction({refer_status: this.referStatus})
.catch((err) => this.logger.error(err, 'TaskSipRefer:exec - error performing action'));
this.notifyTaskDone();
}, 15000);
await this.awaitTaskDone();
if (this._notifyTimer) {
clearTimeout(this._notifyTimer);
this._notifyTimer = null;
}
}
else {
await this.performAction({refer_status: this.referStatus});
@@ -70,10 +81,10 @@ class TaskSipRefer extends Task {
const contentType = req.get('Content-Type');
this.logger.debug({body: req.body}, `TaskSipRefer:_handleNotify got ${contentType}`);
if (contentType === 'message/sipfrag') {
if (contentType?.includes('message/sipfrag')) {
const arr = /SIP\/2\.0\s+(\d+)/.exec(req.body);
if (arr) {
const status = arr[1];
const status = typeof arr[1] === 'string' ? parseInt(arr[1], 10) : arr[1];
this.logger.debug(`TaskSipRefer:_handleNotify: call got status ${status}`);
if (this.eventHook) {
const b3 = this.getTracingPropagation();
@@ -100,6 +111,7 @@ class TaskSipRefer extends Task {
/* they may have only provided a phone number/user */
referTo = `sip:${referTo}@${host}`;
}
else this.referToIsUri = true;
if (!referredBy) {
/* default */
referredBy = cs.req?.callingNumber || dlg.local.uri;

49
lib/tasks/sip_request.js Normal file
View 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;

View File

@@ -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": []
},
@@ -71,7 +84,7 @@
},
"leave": {
"properties": {
}
},
"hangup": {
@@ -83,9 +96,12 @@
},
"play": {
"properties": {
"url": "string",
"url": "string|array",
"loop": "number|string",
"earlyMedia": "boolean"
"earlyMedia": "boolean",
"seekOffset": "number|string",
"timeoutSecs": "number|string",
"actionHook": "object|string"
},
"required": [
"url"
@@ -160,7 +176,8 @@
"timeLimit": "number",
"timeout": "number",
"proxy": "string",
"transcribe": "#transcribe"
"transcribe": "#transcribe",
"amd": "#amd"
},
"required": [
"target"
@@ -296,6 +313,19 @@
"path"
]
},
"recordOptions": {
"properties": {
"action": {
"type": "string",
"enum": ["startCallRecording", "stopCallRecording", "pauseCallRecording", "resumeCallRecording"]
},
"recordingID": "string",
"siprecServerURL": "string"
},
"required": [
"action"
]
},
"redirect": {
"properties": {
"actionHook": "object|string"
@@ -311,6 +341,7 @@
"call_hook": "object|string",
"call_status_hook": "object|string",
"from": "string",
"fromHost": "string",
"speech_synthesis_vendor": "string",
"speech_synthesis_voice": "string",
"speech_synthesis_language": "string",
@@ -357,6 +388,7 @@
"enum": ["GET", "POST"]
},
"headers": "object",
"from": "#dialFrom",
"name": "string",
"number": "string",
"sipUri": "string",
@@ -370,6 +402,14 @@
"type"
]
},
"dialFrom": {
"properties": {
"user": "string",
"host": "string"
},
"required": [
]
},
"auth": {
"properties": {
"username": "string",
@@ -395,7 +435,8 @@
"gender": {
"type": "string",
"enum": ["MALE", "FEMALE", "NEUTRAL"]
}
},
"azureServiceEndpoint": "string"
},
"required": [
"vendor"
@@ -466,7 +507,11 @@
},
"requestSnr": "boolean",
"initialSpeechTimeoutMs": "number",
"azureServiceEndpoint": "string"
"azureServiceEndpoint": "string",
"azureSttEndpointId": "string",
"asrDtmfTerminationDigit": "string",
"asrTimeout": "number",
"audioLogging": "boolean"
},
"required": [
"vendor"
@@ -485,10 +530,29 @@
"properties": {
"enable": "boolean",
"voiceMs": "number",
"mode": "number"
"mode": "number"
},
"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"
}
}
}

View File

@@ -1,5 +1,5 @@
const Emitter = require('events');
const { v4: uuidv4 } = require('uuid');
const uuidv4 = require('uuid-random');
const debug = require('debug')('jambonz:feature-server');
const assert = require('assert');
const {TaskPreconditions} = require('../utils/constants');
@@ -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)) {
@@ -335,6 +336,9 @@ class Task extends Emitter {
}
required = required.filter((item) => item !== dKey);
}
else if (dKey === '_') {
/* no op: allow arbitrary info to be carried here, used by conference e.g in transfer */
}
else throw new Error(`${name}: unknown property ${dKey}`);
}
if (required.length > 0) throw new Error(`${name}: missing value for ${required}`);

View File

@@ -54,14 +54,32 @@ class TaskTranscribe extends Task {
this.requestSnr = recognizer.requestSnr || false;
this.initialSpeechTimeoutMs = recognizer.initialSpeechTimeoutMs || 0;
this.azureServiceEndpoint = recognizer.azureServiceEndpoint;
this.azureSttEndpointId = recognizer.azureSttEndpointId;
this.azureAudioLogging = recognizer.audioLogging;
}
get name() { return TaskName.Transcribe; }
async exec(cs, ep, ep2) {
async exec(cs, {ep, ep2}) {
super.exec(cs);
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
if (cs.hasGlobalSttHints) {
const {hints, hintsBoost} = cs.globalSttHints;
this.hints = this.hints.concat(hints);
if (!this.hintsBoost && hintsBoost) this.hintsBoost = hintsBoost;
this.logger.debug({hints: this.hints, hintsBoost: this.hintsBoost},
'Transcribe:exec - applying global `sttHints');
}
if (cs.hasAltLanguages) {
this.altLanguages = this.altLanguages.concat(cs.altLanguages);
this.logger.debug({altLanguages: this.altLanguages},
'Gather:exec - applying altLanguages');
}
if (cs.hasGlobalSttPunctuation) {
this.punctuation = cs.globalSttPunctuation;
}
this.ep = ep;
this.ep2 = ep2;
if ('default' === this.vendor || !this.vendor) this.vendor = cs.speechRecognizerVendor;
@@ -80,7 +98,9 @@ class TaskTranscribe extends Task {
throw new Error('no provisioned speech credentials for TTS');
}
await this._startTranscribing(cs, ep, 1);
if (this.separateRecognitionPerChannel && ep2) await this._startTranscribing(cs, ep2, 2);
if (this.separateRecognitionPerChannel && ep2) {
await this._startTranscribing(cs, ep2, 2);
}
updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid)
.catch(() => {/*already logged error */});
@@ -102,18 +122,21 @@ 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();
}
@@ -140,6 +163,7 @@ class TaskTranscribe extends Task {
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected, this._onNoAudio.bind(this, cs, ep, channel));
if (this.vendor === 'google') {
this.bugname = 'google_transcribe';
if (this.sttCredentials) opts.GOOGLE_APPLICATION_CREDENTIALS = JSON.stringify(this.sttCredentials.credentials);
[
['enhancedModel', 'GOOGLE_SPEECH_USE_ENHANCED'],
@@ -151,14 +175,16 @@ class TaskTranscribe extends Task {
['diarization', 'GOOGLE_SPEECH_PROFANITY_FILTER']
].forEach((arr) => {
if (this[arr[0]]) opts[arr[1]] = true;
else if (this[arr[0]] === false) opts[arr[1]] = false;
});
if (this.hints.length > 1) {
if (this.hints.length > 0) {
opts.GOOGLE_SPEECH_HINTS = this.hints.join(',');
if (typeof this.hintsBoost === 'number') {
opts.GOOGLE_SPEECH_HINTS_BOOST = this.hintsBoost;
}
}
if (this.altLanguages.length > 1) opts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
if (this.altLanguages.length > 0) opts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
else opts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = '';
if ('unspecified' !== this.interactionType) {
opts.GOOGLE_SPEECH_METADATA_INTERACTION_TYPE = this.interactionType;
}
@@ -175,6 +201,7 @@ class TaskTranscribe extends Task {
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with google'));
}
else if (this.vendor === 'aws') {
this.bugname = 'aws_transcribe';
[
['diarization', 'AWS_SHOW_SPEAKER_LABEL'],
['identifyChannels', 'AWS_ENABLE_CHANNEL_IDENTIFICATION']
@@ -206,14 +233,24 @@ class TaskTranscribe extends Task {
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with aws'));
}
else if (this.vendor === 'microsoft') {
this.bugname = 'azure_transcribe';
const {api_key, region, use_custom_stt, custom_stt_endpoint} = this.sttCredentials;
Object.assign(opts, {
'AZURE_SUBSCRIPTION_KEY': this.sttCredentials.api_key,
'AZURE_REGION': this.sttCredentials.region
'AZURE_SUBSCRIPTION_KEY': api_key,
'AZURE_REGION': region
});
if (this.hints && this.hints.length > 1) {
if (this.azureSttEndpointId) {
Object.assign(opts, {'AZURE_SERVICE_ENDPOINT_ID': this.azureSttEndpointId});
}
else if (use_custom_stt && custom_stt_endpoint) {
Object.assign(opts, {'AZURE_SERVICE_ENDPOINT_ID': custom_stt_endpoint});
}
if (this.hints && this.hints.length > 0) {
opts.AZURE_SPEECH_HINTS = this.hints.map((h) => h.trim()).join(',');
}
if (this.altLanguages.length > 1) opts.AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
if (this.altLanguages.length > 0) opts.AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
else opts.AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = '';
if (this.azureAudioLogging) opts.AZURE_AUDIO_LOGGING = 1;
if (this.requestSnr) opts.AZURE_REQUEST_SNR = 1;
if (this.profanityOption !== 'raw') opts.AZURE_PROFANITY_OPTION = this.profanityOption;
if (this.initialSpeechTimeoutMs > 0) opts.AZURE_INITIAL_SPEECH_TIMEOUT_MS = this.initialSpeechTimeoutMs;
@@ -231,11 +268,16 @@ class TaskTranscribe extends Task {
vendor: this.vendor,
interim: this.interim ? true : false,
locale: this.language,
channels: /*this.separateRecognitionPerChannel ? 2 : */ 1
channels: /*this.separateRecognitionPerChannel ? 2 : */ 1,
bugname: this.bugname
});
}
_onTranscription(cs, ep, channel, evt) {
_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) {
@@ -249,7 +291,7 @@ class TaskTranscribe extends Task {
}) :
[
{
transcript: evt.DisplayText
transcript: evt.DisplayText || evt.Text
}
];

344
lib/utils/amd-utils.js Normal file
View File

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

View File

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

View File

@@ -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"

View File

@@ -42,9 +42,9 @@ const clearChannels = () => {
};
const clearFiles = () => {
const {logger} = require('../..');
const out = execSync('find /tmp -name "*.mp3" -mtime +2 -exec rm {} \\;');
logger.debug({out}, 'clearFiles: command output');
//const {logger} = require('../..');
/*const out = */ execSync('find /tmp -name "*.mp3" -mtime +2 -exec rm {} \\;');
//logger.debug({out}, 'clearFiles: command output');
};

View File

@@ -23,22 +23,30 @@ AND vc.name = ?`;
const speechMapper = (cred) => {
const {credential, ...obj} = cred;
if ('google' === obj.vendor) {
obj.service_key = decrypt(credential);
}
else if ('aws' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.access_key_id = o.access_key_id;
obj.secret_access_key = o.secret_access_key;
}
else if ('microsoft' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
obj.region = o.region;
}
else if ('wellsaid' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
try {
if ('google' === obj.vendor) {
obj.service_key = decrypt(credential);
}
else if ('aws' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.access_key_id = o.access_key_id;
obj.secret_access_key = o.secret_access_key;
obj.aws_region = o.aws_region;
}
else if ('microsoft' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
obj.region = o.region;
obj.use_custom_stt = o.use_custom_stt;
obj.custom_stt_endpoint = o.custom_stt_endpoint;
obj.use_custom_tts = o.use_custom_tts;
obj.custom_tts_endpoint = o.custom_tts_endpoint;
}
else if ('wellsaid' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
}
} catch (err) {
}
return obj;
};

View File

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

View File

@@ -1,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,47 @@ class HttpRequestor extends BaseRequestor {
this.method = hook.method || 'POST';
this.authHeader = basicAuth(hook.username, hook.password);
const u = parseUrl(this.url);
const myPort = u.port ? `:${u.port}` : '';
const baseUrl = this._baseUrl = `${u.protocol}://${u.resource}${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);
if (u.port) this._baseUrl = `${u.protocol}://${u.resource}:${u.port}`;
else this._baseUrl = `${u.protocol}://${u.resource}`;
this._protocol = u.protocol;
this._resource = u.resource;
this._port = u.port;
this._search = u.search;
this._usePools = process.env.HTTP_POOL && parseInt(process.env.HTTP_POOL);
if (this._usePools) {
if (pools.has(this._baseUrl)) {
this.client = pools.get(this._baseUrl);
}
else {
const connections = process.env.HTTP_POOLSIZE ? parseInt(process.env.HTTP_POOLSIZE) : 10;
const pipelining = process.env.HTTP_PIPELINING ? parseInt(process.env.HTTP_PIPELINING) : 1;
const pool = this.client = new Pool(this._baseUrl, {
connections,
pipelining
});
pools.set(this._baseUrl, pool);
this.logger.debug(`HttpRequestor:created pool for ${this._baseUrl}`);
}
}
else {
if (u.port) this.client = new Client(`${u.protocol}://${u.resource}:${u.port}`);
else this.client = new Client(`${u.protocol}://${u.resource}`);
}
}
get baseUrl() {
return this._baseUrl;
}
close() {
if (!this._usePools && !this.client?.closed) this.client.close();
}
/**
* Make an HTTP request.
* All requests use json bodies.
@@ -58,21 +85,60 @@ class HttpRequestor extends BaseRequestor {
const payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null;
const url = hook.url || hook;
const method = hook.method || 'POST';
let buf = '';
assert.ok(url, 'HttpRequestor:request url was not provided');
assert.ok, (['GET', 'POST'].includes(method), `HttpRequestor:request method must be 'GET' or 'POST' not ${method}`);
const {url: urlInfo = hook, method: methodInfo = 'POST'} = hook; // mask user/pass
this.logger.debug({url: urlInfo, method: methodInfo, payload}, `HttpRequestor:request ${method} ${url}`);
const startAt = process.hrtime();
let buf;
let newClient;
try {
let client, path, query;
if (this._isRelativeUrl(url)) {
client = this.client;
path = url;
}
else {
const u = parseUrl(url);
if (u.resource === this._resource && u.port === this._port && u.protocol === this._protocol) {
client = this.client;
path = u.pathname;
query = u.query;
}
else {
if (u.port) client = newClient = new Client(`${u.protocol}://${u.resource}:${u.port}`);
else client = newClient = new Client(`${u.protocol}://${u.resource}`);
path = u.pathname;
query = u.query;
}
}
const sigHeader = this._generateSigHeader(payload, this.secret);
const headers = {...sigHeader, ...this.authHeader, ...httpHeaders};
this.logger.debug({url, headers}, 'send webhook');
buf = this._isRelativeUrl(url) ?
await this.post(url, payload, headers) :
await bent(method, 'buffer', 200, 201, 202)(url, payload, headers);
const hdrs = {
...sigHeader,
...this.authHeader,
...httpHeaders,
...('POST' === method && {'Content-Type': 'application/json'})
};
const absUrl = this._isRelativeUrl(url) ? `${this.baseUrl}${url}` : url;
this.logger.debug({url, absUrl, hdrs}, 'send webhook');
const {statusCode, headers, body} = await client.request({
path,
query,
method,
headers: hdrs,
...('POST' === method && {body: JSON.stringify(payload)}),
timeout: HTTP_TIMEOUT,
followRedirects: false
});
if (![200, 202, 204].includes(statusCode)) {
const err = new Error();
err.statusCode = statusCode;
throw err;
}
if (headers['content-type']?.includes('application/json')) {
buf = await body.json();
}
if (newClient) newClient.close();
} catch (err) {
if (err.statusCode) {
this.logger.info({baseUrl: this.baseUrl, url},
@@ -94,20 +160,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;
}
}
}

View File

@@ -12,7 +12,7 @@ const deepcopy = require('deepcopy');
const moment = require('moment');
const stripCodecs = require('./strip-ancillary-codecs');
const RootSpan = require('./call-tracer');
const { v4: uuidv4 } = require('uuid');
const uuidv4 = require('uuid-random');
class SingleDialer extends Emitter {
constructor({logger, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan}) {
@@ -21,6 +21,7 @@ class SingleDialer extends Emitter {
this.logger = logger;
this.target = target;
this.from = target.from || {};
this.sbcAddress = sbcAddress;
this.opts = opts;
this.application = application;
@@ -66,8 +67,11 @@ class SingleDialer extends Emitter {
opts.headers = {
...opts.headers,
...(this.target.headers || {}),
...(this.from.user && {'X-Preferred-From-User': this.from.user}),
...(this.from.host && {'X-Preferred-From-Host': this.from.host}),
'X-Jambonz-Routing': this.target.type,
'X-Call-Sid': this.callSid
'X-Call-Sid': this.callSid,
...(this.applicationSid && {'X-Application-Sid': this.applicationSid})
};
if (srf.locals.fsUUID) {
opts.headers = {
@@ -408,7 +412,7 @@ class SingleDialer extends Emitter {
this.callInfo.updateCallStatus(callStatus, sipStatus, sipReason);
if (typeof duration === 'number') this.callInfo.duration = duration;
try {
this.requestor.request('call:status', this.application.call_status_hook, this.callInfo.toJSON());
this.notifier.request('call:status', this.application.call_status_hook, this.callInfo.toJSON());
} catch (err) {
this.logger.info(err, `SingleDialer:_notifyCallStatusChange error sending ${callStatus} ${sipStatus}`);
}

View File

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

View File

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

248
lib/utils/siprec-utils.js Normal file
View File

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

View File

@@ -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};
};

View File

@@ -54,7 +54,11 @@ class WsRequestor extends BaseRequestor {
/* if we have an absolute url, and it is http then do a standard webhook */
if (this._isAbsoluteUrl(url) && url.startsWith('http')) {
this.logger.debug({hook}, 'WsRequestor: sending a webhook (HTTP)');
const requestor = new HttpRequestor(this.logger, this.account_sid, hook, this.secret);
const requestor = new HttpRequestor(this.logger, this.account_sid, {url: hook}, this.secret);
if (type === 'session:redirect') {
this.close();
this.emit('handover', requestor);
}
return requestor.request(type, hook, params, httpHeaders);
}
@@ -69,7 +73,7 @@ class WsRequestor extends BaseRequestor {
this.connectInProgress = true;
this.logger.debug(`WsRequestor:request(${this.id}) - connecting since we do not have a connection`);
if (this.connections >= MAX_RECONNECTS) {
throw new Error(`max attempts connecting to ${this.url}`);
return Promise.reject(`max attempts connecting to ${this.url}`);
}
try {
const startAt = process.hrtime();
@@ -79,7 +83,7 @@ class WsRequestor extends BaseRequestor {
} catch (err) {
this.logger.info({url, err}, 'WsRequestor:request - failed connecting');
this.connectInProgress = false;
throw err;
return Promise.reject(err);
}
}
assert(this.ws);
@@ -138,7 +142,7 @@ class WsRequestor extends BaseRequestor {
success: (response) => {
clearTimeout(timer);
const rtt = this._roundTrip(startAt);
this.logger.info({response}, `WsRequestor:request ${url} succeeded in ${rtt}ms`);
this.logger.debug({response}, `WsRequestor:request ${url} succeeded in ${rtt}ms`);
this.stats.histogram('app.hook.ws_response_time', rtt, ['hook_type:app']);
resolve(response);
},
@@ -158,11 +162,12 @@ class WsRequestor extends BaseRequestor {
close() {
this.closedGracefully = true;
this.logger.info('WsRequestor:close closing socket');
this.logger.debug('WsRequestor:close closing socket');
try {
if (this.ws) {
this.ws.close();
this.ws.close(1000);
this.ws.removeAllListeners();
this.ws = null;
}
for (const [msgid, obj] of this.messagesInFlight) {
@@ -187,7 +192,7 @@ class WsRequestor extends BaseRequestor {
followRedirects: true,
maxRedirects: 2,
handshakeTimeout,
maxPayload: 8096,
maxPayload: process.env.JAMBONES_WS_MAX_PAYLOAD ? parseInt(process.env.JAMBONES_WS_MAX_PAYLOAD) : 24 * 1024,
};
if (this.username && this.password) opts = {...opts, auth: `${this.username}:${this.password}`};
@@ -286,7 +291,7 @@ class WsRequestor extends BaseRequestor {
const obj = JSON.parse(content);
const {type, msgid, command, call_sid = this.call_sid, queueCommand = false, data} = obj;
this.logger.debug({obj}, 'WsRequestor:request websocket: received');
//this.logger.debug({obj}, 'WsRequestor:request websocket: received');
assert.ok(type, 'type property not supplied');
switch (type) {
@@ -323,7 +328,7 @@ class WsRequestor extends BaseRequestor {
_recvCommand(msgid, command, call_sid, queueCommand, data) {
// TODO: validate command
this.logger.info({msgid, command, call_sid, queueCommand, data}, 'received command');
this.logger.debug({msgid, command, call_sid, queueCommand, data}, 'received command');
this.emit('command', {msgid, command, call_sid, queueCommand, data});
}
}

10182
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "jambonz-feature-server",
"version": "v0.7.5",
"version": "v0.7.13",
"main": "app.js",
"engines": {
"node": ">= 10.16.0"
@@ -16,62 +16,54 @@
"type": "git",
"url": "https://github.com/jambonz/jambonz-feature-server.git"
},
"bugs": {
"url": "https://github.com/jambonz/jambonz-feature-server/issues"
},
"bugs": {},
"scripts": {
"start": "node app",
"test": "NODE_ENV=test JAMBONES_HOSTING=1 DRACHTIO_HOST=127.0.0.1 DRACHTIO_PORT=9060 DRACHTIO_SECRET=cymru JAMBONES_MYSQL_HOST=127.0.0.1 JAMBONES_MYSQL_PORT=3360 JAMBONES_MYSQL_USER=jambones_test JAMBONES_MYSQL_PASSWORD=jambones_test JAMBONES_MYSQL_DATABASE=jambones_test JAMBONES_REDIS_HOST=127.0.0.1 JAMBONES_REDIS_PORT=16379 JAMBONES_LOGLEVEL=info ENABLE_METRICS=0 HTTP_PORT=3000 JAMBONES_SBCS=172.38.0.10 JAMBONES_FREESWITCH=127.0.0.1:8022:ClueCon:docker-host JAMBONES_TIME_SERIES_HOST=127.0.0.1 JAMBONES_NETWORK_CIDR=172.38.0.0/16 node test/ ",
"test": "NODE_ENV=test JAMBONES_HOSTING=1 HTTP_POOL=1 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.7.4",
"@jambonz/http-health-check": "^0.0.1",
"@jambonz/mw-registrar": "^0.2.1",
"@jambonz/realtimedb-helpers": "^0.4.27",
"@jambonz/realtimedb-helpers": "^0.6.3",
"@jambonz/stats-collector": "^0.1.6",
"@jambonz/time-series": "^0.1.6",
"@jambonz/time-series": "^0.2.5",
"@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.16",
"drachtio-srf": "^4.5.21",
"express": "^4.18.2",
"ip": "^1.1.8",
"moment": "^2.29.4",
"parse-url": "^8.1.0",
"pino": "^6.14.0",
"sdp-transform": "^2.14.1",
"short-uuid": "^4.2.0",
"to-snake-case": "^1.0.0",
"uuid": "^8.3.2",
"verify-aws-sns-signature": "^0.0.6",
"ws": "^8.5.0",
"undici": "^5.11.0",
"uuid-random": "^1.3.2",
"verify-aws-sns-signature": "^0.1.0",
"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",

108
test/create-call-test.js Normal file
View File

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

View File

@@ -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 */;

View File

@@ -12,7 +12,7 @@ services:
platform: linux/x86_64
ports:
- "3360:3306"
environment:
environment:
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "127.0.0.1", "--protocol", "tcp"]
@@ -75,10 +75,8 @@ services:
fs:
ipv4_address: 172.38.0.51
webhook-decline:
webhook-scaffold:
image: jambonz/webhook-test-scaffold:latest
environment:
APP_PATH: /tmp/decline.json
ports:
- "3100:3000/tcp"
volumes:
@@ -87,42 +85,6 @@ services:
fs:
ipv4_address: 172.38.0.60
webhook-say:
image: jambonz/webhook-test-scaffold:latest
environment:
APP_PATH: /tmp/say.json
ports:
- "3101:3000/tcp"
volumes:
- ./test-apps:/tmp
networks:
fs:
ipv4_address: 172.38.0.61
webhook-gather:
image: jambonz/webhook-test-scaffold:latest
environment:
APP_PATH: /tmp/gather.json
ports:
- "3102:3000/tcp"
volumes:
- ./test-apps:/tmp
networks:
fs:
ipv4_address: 172.38.0.62
webhook-transcribe:
image: jambonz/webhook-test-scaffold:latest
environment:
APP_PATH: /tmp/transcribe.json
ports:
- "3103:3000/tcp"
volumes:
- ./test-apps:/tmp
networks:
fs:
ipv4_address: 172.38.0.63
influxdb:
image: influxdb:1.8
ports:

View File

@@ -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) => {

View File

@@ -3,6 +3,7 @@ const { sippUac } = require('./sipp')('test_fs');
const bent = require('bent');
const getJSON = bent('json')
const clearModule = require('clear-module');
const {provisionCallHook} = require('./utils')
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
@@ -22,9 +23,25 @@ test('\'gather\' and \'transcribe\' tests', async(t) => {
try {
await connect(srf);
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10');
let obj = await getJSON('http://127.0.0.1:3102/actionHook');
t.ok(obj.speech.alternatives[0].transcript = 'I\'d like to speak to customer support',
// GIVEN
let verbs = [
{
"verb": "gather",
"input": ["speech"],
"recognizer": {
"vendor": "google",
"hints": ["customer support", "sales", "human resources", "HR"]
},
"timeout": 10,
"actionHook": "/actionHook"
}
];
let from = "gather_success";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
t.ok(obj.body.speech.alternatives[0].transcript = 'I\'d like to speak to customer support',
'gather: succeeds when using account credentials');
disconnect();

View File

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

198
test/play-tests.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

100
test/sip-refer-tests.js Normal file
View File

@@ -0,0 +1,100 @@
const test = require('tape');
const { sippUac } = require('./sipp')('test_fs');
const clearModule = require('clear-module');
const {provisionCallHook, provisionCustomHook, provisionActionHook} = require('./utils')
const bent = require('bent');
const getJSON = bent('json')
const sleepFor = async(ms) => new Promise(resolve => setTimeout(resolve, ms));
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
function connect(connectable) {
return new Promise((resolve, reject) => {
connectable.on('connect', () => {
return resolve();
});
});
}
test('\'refer\' tests w/202 and NOTIFY', {timeout: 25000}, async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
const verbs = [
{
verb: 'say',
text: 'silence_stream://100'
},
{
verb: 'sip:refer',
referTo: '123456',
actionHook: '/actionHook'
}
];
const noVerbs = [];
const from = 'refer_with_notify';
provisionCallHook(from, verbs);
provisionActionHook(from, noVerbs)
// THEN
await sippUac('uac-refer-with-notify.xml', '172.38.0.10', from);
t.pass('refer: successfully received 202 Accepted');
await sleepFor(1000);
const obj = await getJSON(`http:127.0.0.1:3100/lastRequest/${from}_actionHook`);
t.ok(obj.body.final_referred_call_status === 200, 'refer: successfully received NOTIFY with 200 OK');
//console.log(`obj: ${JSON.stringify(obj)}`);
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'refer\' tests w/202 but no NOTIFY', {timeout: 25000}, async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
const verbs = [
{
verb: 'say',
text: 'silence_stream://100'
},
{
verb: 'sip:refer',
referTo: '123456',
actionHook: '/actionHook'
}
];
const noVerbs = [];
const from = 'refer_no_notify';
provisionCallHook(from, verbs);
provisionActionHook(from, noVerbs)
// THEN
await sippUac('uac-refer-no-notify.xml', '172.38.0.10', from);
t.pass('refer: successfully received 202 Accepted w/o NOTIFY');
await sleepFor(17000);
const obj = await getJSON(`http:127.0.0.1:3100/lastRequest/${from}_actionHook`);
console.log(`obj: ${JSON.stringify(obj)}`);
t.ok(obj.body.refer_status === 202, 'refer: successfully timed out and reported 202');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});

55
test/sip-request-tests.js Normal file
View File

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

View File

@@ -1,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);
@@ -55,6 +53,13 @@ test('incoming call tests', (t) => {
.then(() => {
return t.pass('handles in-dialog requests');
})
.then(() => {
return sippUac('uac-refer-no-notify.xml', '172.38.0.30');
})
.then(() => {
return t.pass('handles sip:refer where we get 202 but no NOTIFY');
})
.then(() => {
srf.disconnect();
t.end();

View File

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

15
test/test-apps/info.json Normal file
View 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"
}
]

36
test/utils.js Normal file
View File

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

View File

@@ -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" ]

View File

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

View File

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

View File

@@ -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",

View File

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

View File

@@ -22,7 +22,7 @@ module.exports = (serviceName) => {
});
let exporter;
if (process.env.OTEL_EXPORTER_JAEGER_AGENT_HOST) {
if (process.env.OTEL_EXPORTER_JAEGER_AGENT_HOST || process.env.OTEL_EXPORTER_JAEGER_ENDPOINT) {
exporter = new JaegerExporter();
}
else if (process.env.OTEL_EXPORTER_ZIPKIN_URL) {