Compare commits

...

162 Commits

Author SHA1 Message Date
Dave Horton
145ed488db make the feature committed in dd4d9aa enabled only if JAMBONES_GATHER_CLEAR_GLOBAL_HINTS_ON_EMPTY_HINTS is set, as it is a behavior change 2023-03-23 07:54:39 -04:00
Dave Horton
c06a43adfa Gather: bugfix for alternate languages with Azure 2023-03-22 14:32:25 -04:00
Dave Horton
bebc82d194 bugfix: gather with google STT does not need to restart transcribing after end of utterance 2023-03-21 15:46:00 -04:00
Dave Horton
cdc82e99ff add minor logging 2023-03-21 12:35:02 -04:00
Dave Horton
dd4d9aa261 Gather: if an empty array of hints are supplied, this signals we should mask global hints for this collection 2023-03-21 12:16:12 -04:00
Dave Horton
1dcf9ee5a2 update to speech-utils@0.0.6 2023-03-21 08:27:25 -04:00
Dave Horton
4b28db0946 update to speech-utils@.0.0.5 2023-03-21 08:00:52 -04:00
Dave Horton
e7ff76b938 update to speech-utils with AWS tts bugfix 2023-03-20 15:35:20 -04:00
Dave Horton
f245275983 gather: remove duplicate and null hints, restart timeout on interim transcripts 2023-03-20 15:34:55 -04:00
Dave Horton
690deed89d prune unused logging 2023-03-19 12:04:02 -04:00
Dave Horton
26053ec709 update speech-utils with support for more audio formats for custom tts 2023-03-15 09:14:41 -04:00
Dave Horton
34e8203338 update to realtime-dbhelpers that factored out speech-utils 2023-03-14 10:07:29 -04:00
Hoan Luu Huu
7be3c64116 feat: update speech-ultil version 1.0.1 (#275)
* feat: update speech-ultil version 1.0.1

* feat: update speech-ultil version 1.0.1

* more fixes for custom stt

* more fixes

* fixes

* update drachtio-fsmrf

* pass url to mod_jambonz_transcribe

* transcription utils: handle custom results

* handle custom speech vendor errors

* add support for hints to custom speech

* change to custom speech options

* send hints as an array for custom speech

* update latest speech-utils

* transcribe: changes to support soniox

* bugfix: soniox transcribe

---------

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
Co-authored-by: Dave Horton <daveh@beachdognet.com>
2023-03-12 19:38:36 -04:00
Hoan Luu Huu
f71d3aed8b feat: forward PAI from inbound call to dial outbound call (#280)
* feat: forward PAI from inbound call to dial outbound call

* fix: review comment

---------

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
2023-03-09 08:58:19 -05:00
Hoan Luu Huu
5ab24337b2 fix: use TTS_FAILURE alert type for synthAudio (#278)
Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
2023-03-08 07:42:06 -05:00
Dave Horton
2af76d94a6 bugfix: repeated ws failures should stop eventually 2023-03-07 16:29:00 -05:00
Dave Horton
4919c05181 add verb:status for play events (#274) 2023-03-03 15:56:50 -05:00
Dave Horton
3084a9d6ba #241 - gather bargein on Nuance has to be based on start of speech event (#246) 2023-03-03 13:39:23 -05:00
Dave Horton
1c683f1142 initial changes for soniox (#270)
* initial changes for soniox

* changes to gather for soniox

* parse soniox stt results

* handle <end> token for soniox

* soniox: handle empty array of words

* support for soniox hints

* add soniox storage options

* update to verb specs

* add support for transcribe

* compile soniox transcripts

* gather: kill no input timer for soniox when we get interim results

* fix buffering of soniox transcripts

* fix for compiling soniox transcript

* another fix for compiling soniox transcript

* another fix

* handling of <end> token

* fix soniox bug

* gather: fixes for soniox continous asr

* fix undefined variable reference

* fix prev commit

* bugfix: allow verb_status requests

* gather: for soniox no need to restart transcription after final transcription received

* update verb specs

* update verb specs, fixes for continuous asr:
2023-03-03 13:37:55 -05:00
Dave Horton
ab1947e23e bugfix: gather minBargeinWordCount defaults to 1 2023-02-24 10:27:05 -05:00
Dave Horton
5527abff09 bump version 2023-02-24 10:04:25 -05:00
Dave Horton
68827112fc further fix for early hints match in gather 2023-02-23 13:10:46 -05:00
Dave Horton
8a9a2df128 early hints fix that was not merged 2023-02-23 12:54:21 -05:00
Dave Horton
3a3544a5e8 remove some wordy logging 2023-02-23 12:32:41 -05:00
Dave Horton
cbeb706946 update to latest @jambonz/verb-specifications with less verbose logging 2023-02-23 12:16:14 -05:00
Dave Horton
f005262615 docs 2023-02-23 10:48:09 -05:00
Snyk bot
67ec28484c fix: package.json & package-lock.json to reduce vulnerabilities (#265)
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-UNDICI-3323844
- https://snyk.io/vuln/SNYK-JS-UNDICI-3323845
2023-02-23 10:26:06 -05:00
two56
803a944240 Use the request from CallSession for cancel (#268)
* Use the req from CallSession for cancel

* Check cs is set

---------

Co-authored-by: Matt Preskett <matt.preskett@netcall.com>
2023-02-23 09:13:44 -05:00
Snyk bot
a5cd342e46 fix: Dockerfile to reduce vulnerabilities (#269) 2023-02-22 14:04:39 -05:00
EgleH
e91feb64f5 Update node base image to node:18.14.0-alpine3.16 (#267) 2023-02-21 07:54:00 -05:00
Dave Horton
ae688ddc7e when handling reinvites for SIPREC incoming calls just respond 200 OK with existing sdp 2023-02-17 09:12:54 -05:00
Dave Horton
9b21b65478 fix uncaught exception in certain ws reconnect scenarios 2023-02-15 20:29:47 -05:00
Hoan Luu Huu
c09425fa89 feat: use verb-specifications (#262)
* feat: use verb-specifications

* feat: use verb-specifications

* fix: verb specification v2

* remove irrelevant tests

* fix: verb-scpecification

* update to use @jambonz/verb-specifications

---------

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
Co-authored-by: Dave Horton <daveh@beachdognet.com>
2023-02-15 09:56:23 -05:00
Dave Horton
6706992b4b update to latest @jambonz/realtimedb-helpers 2023-02-14 09:00:00 -05:00
Dave Horton
0fdcb3a6d6 Feature/nvidia speech (#261)
* initial changes for nvidia speech

* allow nvidia speech credentials to be set at runtime

* update drachtio-fsmrf

* fix handling of nvidia-specific options

* fix nvidia custom config

* fix nvidia word time offsets

* fix nvidia custom configuration

* normalize nvidia transcripts

* update to @jambonz/realtime-dbhelpers with nvidia tts support
2023-02-12 14:06:01 -05:00
Snyk bot
50057deca9 fix: Dockerfile to reduce vulnerabilities (#260)
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-ALPINE316-OPENSSL-3314623
- https://snyk.io/vuln/SNYK-ALPINE316-OPENSSL-3314624
- https://snyk.io/vuln/SNYK-ALPINE316-OPENSSL-3314624
- https://snyk.io/vuln/SNYK-ALPINE316-OPENSSL-3314641
- https://snyk.io/vuln/SNYK-ALPINE316-OPENSSL-3314643
2023-02-12 12:43:38 -05:00
Dave Horton
c7eacdd0f8 bugfix: switching to http webhook during a ws session 2023-02-11 21:09:48 -05:00
Dave Horton
e990b5dbf9 proper shut down in K8S (#253) 2023-02-07 19:54:34 -05:00
Dave Horton
7ae37b1e60 #251 - unhandled exception in Session#_notifyCallStatusChange 2023-02-07 14:51:54 -05:00
Dave Horton
ed284c367d update db-helpers 2023-02-07 13:34:11 -05:00
Dave Horton
272380dc62 fix vulnerability 2023-02-07 08:31:36 -05:00
Dave Horton
61dbb659b3 fix lint errors 2023-02-07 08:29:32 -05:00
Vinod Dharashive
fbae7c0eab notifyError object jambonz:error (#247)
Consist notifyerror for jambonz:error for asr
2023-02-07 08:21:25 -05:00
Dave Horton
4894a85569 bugfix: recognizer.punctuation not working on nuance 2023-02-06 12:07:28 -05:00
Hoan Luu Huu
0e5bb876ce ws-requestor unit test (#244)
* ws-requestor unit test

* ws-requestor unit test

* ws-requestor unit test

* handle special case of reconnecting during the initial session:new - ack transaction

* fix: add more wsrequestor unit test

* fix: add more wsrequestor unit test

---------

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
Co-authored-by: Dave Horton <daveh@beachdognet.com>
2023-02-06 08:06:41 -05:00
Dave Horton
8658d03f1f add mixType to config.listen 2023-02-04 16:08:56 -05:00
Dave Horton
f2ff5250b0 reset variables like hints so that previous hints do not automatically carry over (#243) 2023-02-03 13:36:14 -05:00
Dave Horton
c37fba541f for backwards compatibility in gather/transcribe return only 1 alternative at top level (#240) 2023-02-02 12:58:22 -05:00
Dave Horton
f9921cf4e9 #238 - add 'listen' option to config verb (#239) 2023-01-31 14:39:55 -05:00
Dave Horton
86fed4ec90 gather: fix bug referencing this.hints which does not exist (hints are part of recognizer object) 2023-01-30 18:21:36 -05:00
Hoan Luu Huu
9d07a1354c feat: support app_json in application (#236)
* feat: support app_json in application

* feat: support app_json in application

* update db schema to latest

---------

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
Co-authored-by: Dave Horton <daveh@beachdognet.com>
2023-01-29 14:35:29 -05:00
Dave Horton
2775c7ddd1 update drachtio-srf 2023-01-29 13:59:33 -05:00
Dave Horton
70822cb278 test fix: ACK to 487 response must have same branch in via as invite 2023-01-29 13:23:11 -05:00
Hoan Luu Huu
14a02735be fix: uncomment testsuite (#234)
* fix: uncomment testsuite

* fix: uncomment testsuite

* fix: unstable testcases

* fix: uncomment testsuite

* fix: uncomment testsuite

* fix: uncomment testsuite

* fix: uncomment testsuite

* fix: uncomment testsuite

* fix: uncomment testsuite

* fix: unstable testcase

* fix: uncomment testsuite

* wip: test

* wip: test

* wip: test

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
2023-01-27 07:27:53 -05:00
Hoan Luu Huu
4b3ebe37ac fix: add early media testcase (#233)
* fix: add early media testcase

* fix: add early media testcase

* fix: add early media testcase

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
2023-01-26 13:29:31 -05:00
Hoan Luu Huu
f4fbd07f8e feat: add dial verb testcases (#231)
* feat: add dial verb testcases

* feat: add dial verb testcases

* feat: add dial verb testcases

* feat: add dial verb testcases

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
2023-01-25 10:39:01 -05:00
Hoan Luu Huu
6ebba8673f feat: listen verb testsuite (#222)
* first draft

* first draft

* listen should connect to port 3000 on webhook scaffold

* revamp webhook scaffold for listen ws support

* fix: finished listen test

* fix: add playbeep test listen

* fix: add playbeep test listen

* fixed: listen on 10% loss

* feat: add test case for listen pause resume and complete the call

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
Co-authored-by: Dave Horton <daveh@beachdognet.com>
2023-01-24 22:20:31 -05:00
dependabot[bot]
2b06177dc5 Bump jsonwebtoken and ibm-cloud-sdk-core (#227)
Bumps [jsonwebtoken](https://github.com/auth0/node-jsonwebtoken) and [ibm-cloud-sdk-core](https://github.com/IBM/node-sdk-core). These dependencies needed to be updated together.

Updates `jsonwebtoken` from 8.5.1 to 9.0.0
- [Release notes](https://github.com/auth0/node-jsonwebtoken/releases)
- [Changelog](https://github.com/auth0/node-jsonwebtoken/blob/master/CHANGELOG.md)
- [Commits](https://github.com/auth0/node-jsonwebtoken/compare/v8.5.1...v9.0.0)

Updates `ibm-cloud-sdk-core` from 3.2.1 to 3.2.2
- [Release notes](https://github.com/IBM/node-sdk-core/releases)
- [Changelog](https://github.com/IBM/node-sdk-core/blob/main/CHANGELOG.md)
- [Commits](https://github.com/IBM/node-sdk-core/commits)

---
updated-dependencies:
- dependency-name: jsonwebtoken
  dependency-type: indirect
- dependency-name: ibm-cloud-sdk-core
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-24 10:03:39 -05:00
Hoan Luu Huu
088316d266 fix: split ssml to correct chunks (#225)
* fix: split ssml to correct chunks

* fix: split ssml to correct chunks

* fixed: eslint

* fixed: eslint

* fixed: add comment to testcase

* fixed: review comments

* fixed: review comments

* fixed: review comments

* fixed: review comments

* fixed: review comments

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
2023-01-24 09:48:31 -05:00
Hoan Luu Huu
8c0044a378 Weebhook app test support ws (#226)
* feat: webhook test app support ws

* feat: webhook test app support ws

* feat: webhook test app support ws

* feat: webhook test app support ws

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
2023-01-24 09:04:37 -05:00
Dave Horton
dae307d71f when closing websocket at end of call send ws code 1000 2023-01-22 12:46:20 -05:00
Dave Horton
1b5b37184b bugfix #223: early hints should not be enabled with continuous asr is used in gather 2023-01-19 09:48:12 -05:00
Dave Horton
2f8efb80d0 bugfix #220 (config w/ bargein enable followed later in flow w bargein disable) 2023-01-19 09:31:23 -05:00
Dave Horton
c57e88b496 update .gitignore 2023-01-17 08:01:50 -05:00
Dave Horton
7122d955fe fix for #217 - refer blocks if notify nor bye received 2023-01-17 08:01:50 -05:00
Hoan Luu Huu
028aeea856 fix: wrong stt vendor will raise ws notification (#219)
* fix: wrong stt vendor will raise ws notification

* fix: wrong stt vendor will raise ws notification

Co-authored-by: Quan HL <quan.luuhoan8@gmail.com>
2023-01-17 07:52:05 -05:00
Dave Horton
567b03fd36 bugfix: transcribe/gather using default as vendor 2023-01-11 15:31:24 -05:00
Dave Horton
d5c04d2133 transcribe and gather: silently discard listening events from ibm stt 2023-01-11 14:59:15 -05:00
Dave Horton
a2e909b057 fix analysis of play test 2023-01-10 13:50:58 -05:00
Dave Horton
c3627cecb8 avoid hanging up on websocket apps after initial set of commands have completed 2023-01-10 08:28:59 -05:00
Dave Horton
6753fdc2b4 linting error 2023-01-09 09:30:29 -05:00
two56
740d996739 Fix/play timeout (#183)
* Move play timeout into jambonz

* Unnecessary await on kill + eslint

* Change call status test to be consistent

Co-authored-by: Matt Preskett <matt.preskett@netcall.com>
2023-01-09 09:27:20 -05:00
two56
714d06a600 Fix/conference wait hook (#213)
* Deref old wait_hook on change

* Kill running playSession on conference exit

Co-authored-by: Matt Preskett <matt.preskett@netcall.com>
2023-01-09 08:13:34 -05:00
Dave Horton
0c52324915 Bugfix/http redirect to ws (#211)
* allow redirect to ws(s) url after starting with http(s) #210

* further fixes for #210
2023-01-07 15:00:18 -05:00
Dave Horton
2e3fb60e72 support google hints as an array of objects containing both hint phra… (#209)
* support google hints as an array of objects containing both hint phrase and boost value

* handle structured hints for non-google STT (#205)
2023-01-06 08:00:17 -05:00
Dave Horton
05a4665f87 Feature/force tts generation (#208)
* Feature: add option synthesizer.forceTtsGeneration #198

* Feature: add option synthesizer.forceTtsGeneration #198

* minor cleanup

* minor

Co-authored-by: Michal Tesar <michal@irevolution.group>
2023-01-04 15:42:48 -05:00
Dave Horton
b16d49d8ea 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 15:19:35 -05:00
Dave Horton
aad2d52efd fix #206: prevent 2 simultaneous background gathers 2023-01-03 10:04:51 -05:00
Dave Horton
83d767116b add support for http transport for jaeger 2022-12-30 10:47:31 -05:00
Dave Horton
b4673ad942 update to latest drachtio-srf and realtimedb-helpers 2022-12-29 10:22:16 -05:00
Dave Horton
9b8bb07a97 update to latest drachtio-fsmrf 2022-12-28 11:05:06 -06:00
Dave Horton
29f578ff5c faster uuid 2022-12-28 10:40:26 -06:00
Dave Horton
6d86793494 update to latest drachtio-srf and drachtio-fsmrf 2022-12-21 12:26:51 -05:00
Dave Horton
9f95fde67e faster uuid generator 2022-12-21 08:27:00 -05:00
Dave Horton
010b4d2778 bugfix: db caching had side affects of using closed http requestors 2022-12-13 14:55:23 -05:00
Dave Horton
8d81c20c1a Feature/fsrmf perf improvement (#197)
* update drachtio-fsmrf

* update drachtio-fsmrf

* sync package-lock.json
2022-12-11 12:12:50 -05:00
Dave Horton
69f796e960 update gh actions 2022-12-10 15:32:50 -05:00
Dave Horton
4db03d3d1b update to drachtio-fsmrf@3.0.8 with performance improvements for call setup 2022-12-10 15:12:15 -05:00
Dave Horton
a60c6a4740 add support for ws verb:status event notifications (#196) 2022-12-09 21:11:47 -05:00
dependabot[bot]
5b875c3ad4 Bump qs and express in /test/webhook (#195)
Bumps [qs](https://github.com/ljharb/qs) to 6.11.0 and updates ancestor dependency [express](https://github.com/expressjs/express). These dependencies need to be updated together.


Updates `qs` from 6.7.0 to 6.11.0
- [Release notes](https://github.com/ljharb/qs/releases)
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.7.0...v6.11.0)

Updates `express` from 4.17.1 to 4.18.2
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.17.1...4.18.2)

---
updated-dependencies:
- dependency-name: qs
  dependency-type: indirect
- dependency-name: express
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-09 20:34:00 -05:00
Dave Horton
bf19d2ae6d fixes for runtime speech credentials 2022-12-09 20:25:39 -05:00
Dave Horton
37efdc62be fix prev commit 2022-12-09 11:07:41 -05:00
Dave Horton
78a76bb1f4 bugfix: when handing over from wss to http close the wss socket 2022-12-09 10:57:59 -05:00
Dave Horton
39fb762a15 ibm speech fix 2022-12-04 11:28:02 -05:00
Dave Horton
2cc3140de0 bugfix #192: config with dtmf only followed later by gather with speech not working 2022-12-01 13:05:11 -05:00
Dave Horton
1a1f2770b6 include service_provider_sid in call webhook 2022-11-30 13:00:35 -05:00
Dave Horton
23f3b44b8b add custom header on Refer indicating whether sbc-inbound should fix up the Refer-To 2022-11-30 13:00:03 -05:00
Dave Horton
753d46e513 error handling in amd 2022-11-22 15:39:37 -05:00
Dave Horton
71a2435c63 Feature/ibm watson (#193)
* initial changes to support ibm watson

* update specs.json for ibm

* update to drachtio-fsmrf with support for ibm

* bugfix: set access token for ibm stt, not api_key

* fix name of api_key

* normalize ibm transcription results

* rework ibm credentials

* bugfix setting runtime speech creds

* bugfix: ibm region

* typo

* changes to transcribe for ibm watson

* implement connect handler

* bugfix: bind error

* proper use of result_index

* ibm error handling
2022-11-21 22:09:37 -05:00
Dave Horton
8686348454 Feature/deepgram stt (#190)
* initial changes to support deepgram stt

* fixes for normalizing vendor-specific transcriptions

* update to latest drachtio-fsmrf with support for deepgram stt

* deepgram parsing error

* hints support for deepgram

* handling deepgram errors

* ignore late arriving transcripts for deepgram

* handling of empty transcripts

* transcribe changes

* allow deepgram stt credentials to be provided at run time

* bind channel in transcription handler

* fixes for transcribe when handling empty transcripts

* more empty transcript fixes

* update tests to latest modules

* add test cases for deepgram speech recognition
2022-11-12 19:48:59 -05:00
Guilherme Rauen
f511e6ab6b update node image to the latest and most secure (#189)
Co-authored-by: Guilherme Rauen <g.rauen@cognigy.com>
2022-11-11 11:22:07 -05:00
Dave Horton
706cd4b94b bugfix: handle gather/transcribe where vendor not explicitly specified #187 2022-11-07 09:31:51 -05:00
Dave Horton
e5c209e269 fix for #186: unhandled error when amd webhook returns non-success status code 2022-11-06 09:41:34 -05:00
Dave Horton
d903dbe28d update deps to latest db/realtime-db 2022-11-06 09:39:51 -05:00
Dave Horton
d88321c24d fixes for custom voice testing in azure 2022-11-06 09:37:22 -05:00
Dave Horton
6e1761bab6 update to db-helpers with caching fix 2022-11-01 20:56:51 -04:00
Dave Horton
509bb065bb Feature/nuance stt (#185)
* initial changes to gather to support nuance stt

* updateSpeechCredentialLastUsed could be called without a speech_credential_sid if credentials are passed in the flow

* fix bugname

* typo

* added handlers for nuance

* logging

* major refactor of parsing transcriptions

* initial support for nuance in transcribe verb

* updates from testing

* cleanup some tests

* update action

* typo

* gather: start nuance timers after say/play completes

* update drachtio-fsrmf

* refactor some code

* typo

* log nuance error detail

* timeout handling

* typo

* handle nuance 413 response when recognition times out

* typo in specs.json

* add support for nuance resources

* fixes and tests for transcribe

* remove logging from test

* initial support for kryptonEndpoint

* try getting access token even when using krypton

* typo in kryptonEndpoint property

* add support for Nuance tts

* parse nuance voice and model for tts

* use nuance credentials from db

* update to db-helpers@0.7.0 with caching option

* add support for azure audio logging in gather/transcribe

* sync package-lock.json
2022-11-01 12:23:49 -04:00
Dave Horton
203b9774ca bugfix: ws error max connections error causes a crash 2022-11-01 11:42:08 -04:00
Dave Horton
fade47d423 bugfix when running multiple instances in EC2 2022-11-01 11:42:01 -04:00
Dave Horton
26e52d131e update to db-helpers@0.7.0 with caching option 2022-11-01 11:41:53 -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
100 changed files with 13629 additions and 4583 deletions

View File

@@ -1,16 +1,15 @@
name: CI name: CI
on: on: [push, pull_request]
push:
jobs: jobs:
build: build:
runs-on: ubuntu-20.04 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: actions/setup-node@v1 - uses: actions/setup-node@v3
with: with:
node-version: 14 node-version: 16
- run: npm ci - run: npm ci
- run: npm run jslint - run: npm run jslint
- run: docker pull drachtio/sipp - run: docker pull drachtio/sipp
@@ -20,3 +19,5 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: ${{ secrets.AWS_REGION }} 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' if: github.event_name == 'push'
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Build image - name: Build image
run: docker build . --file Dockerfile --tag $IMAGE_NAME run: docker build . --file Dockerfile --tag $IMAGE_NAME

3
.gitignore vendored
View File

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

View File

@@ -1,4 +1,4 @@
FROM --platform=linux/amd64 node:18.6.0-alpine as base FROM --platform=linux/amd64 node:18.14.1-alpine3.16 as base
RUN apk --update --no-cache add --virtual .builds-deps build-base python3 RUN apk --update --no-cache add --virtual .builds-deps build-base python3

View File

@@ -20,6 +20,7 @@ Configuration is provided via environment variables:
|ENABLE_METRICS| if 1, metrics will be generated|no| |ENABLE_METRICS| if 1, metrics will be generated|no|
|GOOGLE_APPLICATION_CREDENTIALS| path to gcp service key file|yes| |GOOGLE_APPLICATION_CREDENTIALS| path to gcp service key file|yes|
|HTTP_PORT| tcp port to listen on for API requests from jambonz-api-server|yes| |HTTP_PORT| tcp port to listen on for API requests from jambonz-api-server|yes|
|JAMBONES_GATHER_EARLY_HINTS_MATCH| if true and hints are provided, gather will opportunistically review interim transcripts if possible to reduce ASR latency |no|
|JAMBONES_FREESWITCH| IP:port:secret for Freeswitch server (e.g. '127.0.0.1:8021:JambonzR0ck$'|yes| |JAMBONES_FREESWITCH| IP:port:secret for Freeswitch server (e.g. '127.0.0.1:8021:JambonzR0ck$'|yes|
|JAMBONES_LOGLEVEL| log level for application, 'info' or 'debug'|no| |JAMBONES_LOGLEVEL| log level for application, 'info' or 'debug'|no|
|JAMBONES_MYSQL_HOST| mysql host|yes| |JAMBONES_MYSQL_HOST| mysql host|yes|

83
app.js
View File

@@ -15,12 +15,9 @@ const tracer = require('./tracer')(process.env.JAMBONES_OTEL_SERVICE_NAME || 'ja
const api = require('@opentelemetry/api'); const api = require('@opentelemetry/api');
srf.locals = {...srf.locals, otel: {tracer, api}}; srf.locals = {...srf.locals, otel: {tracer, api}};
const PORT = process.env.HTTP_PORT || 3000; const opts = {level: process.env.JAMBONES_LOGLEVEL || 'info'};
const opts = { const pino = require('pino');
timestamp: () => {return `, "time": "${new Date().toISOString()}"`;}, const logger = pino(opts, pino.destination({sync: false}));
level: process.env.JAMBONES_LOGLEVEL || 'info'
};
const logger = require('pino')(opts);
const {LifeCycleEvents, FS_UUID_SET_NAME} = require('./lib/utils/constants'); const {LifeCycleEvents, FS_UUID_SET_NAME} = require('./lib/utils/constants');
const installSrfLocals = require('./lib/utils/install-srf-locals'); const installSrfLocals = require('./lib/utils/install-srf-locals');
installSrfLocals(srf, logger); installSrfLocals(srf, logger);
@@ -28,24 +25,15 @@ installSrfLocals(srf, logger);
const { const {
initLocals, initLocals,
createRootSpan, createRootSpan,
handleSipRec,
getAccountDetails, getAccountDetails,
normalizeNumbers, normalizeNumbers,
retrieveApplication, retrieveApplication,
invokeWebCallback invokeWebCallback
} = require('./lib/middleware')(srf, logger); } = 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 InboundCallSession = require('./lib/session/inbound-call-session');
const SipRecCallSession = require('./lib/session/siprec-call-session');
if (process.env.DRACHTIO_HOST) { if (process.env.DRACHTIO_HOST) {
srf.connect({host: process.env.DRACHTIO_HOST, port: process.env.DRACHTIO_PORT, secret: process.env.DRACHTIO_SECRET }); 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', [ srf.use('invite', [
initLocals, initLocals,
createRootSpan, createRootSpan,
handleSipRec,
getAccountDetails, getAccountDetails,
normalizeNumbers, normalizeNumbers,
retrieveApplication, retrieveApplication,
invokeWebCallback invokeWebCallback
]); ]);
srf.invite((req, res) => { srf.invite(async(req, res) => {
const session = new InboundCallSession(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(); 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'); const sessionTracker = srf.locals.sessionTracker = require('./lib/session/session-tracker');
sessionTracker.on('idle', () => { sessionTracker.on('idle', () => {
if (srf.locals.lifecycleEmitter.operationalState === LifeCycleEvents.ScaleIn) { if (srf.locals.lifecycleEmitter.operationalState === LifeCycleEvents.ScaleIn) {
@@ -100,34 +77,54 @@ sessionTracker.on('idle', () => {
srf.locals.lifecycleEmitter.scaleIn(); srf.locals.lifecycleEmitter.scaleIn();
} }
}); });
const getCount = () => sessionTracker.count; const getCount = () => sessionTracker.count;
const healthCheck = require('@jambonz/http-health-check'); 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(() => { setInterval(() => {
srf.locals.stats.gauge('fs.sip.calls.count', sessionTracker.count); srf.locals.stats.gauge('fs.sip.calls.count', sessionTracker.count);
}, 5000); }, 20000);
const disconnect = () => { const disconnect = () => {
return new Promise ((resolve) => { return new Promise ((resolve) => {
httpServer.on('close', resolve); httpServer?.on('close', resolve);
httpServer.close(); httpServer?.close();
srf.disconnect(); srf.disconnect();
srf.locals.mediaservers.forEach((ms) => ms.disconnect()); srf.locals.mediaservers.forEach((ms) => ms.disconnect());
}); });
}; };
process.on('SIGUSR2', handle);
process.on('SIGTERM', handle); process.on('SIGTERM', handle);
function handle(signal) { function handle(signal) {
const {removeFromSet} = srf.locals.dbHelpers; const {removeFromSet} = srf.locals.dbHelpers;
const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-fs`;
logger.info(`got signal ${signal}, removing ${srf.locals.localSipAddress} from set ${setName}`);
removeFromSet(setName, srf.locals.localSipAddress);
removeFromSet(FS_UUID_SET_NAME, srf.locals.fsUUID);
srf.locals.disabled = true; srf.locals.disabled = true;
logger.info(`got signal ${signal}`);
const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-fs`;
if (setName && srf.locals.localSipAddress) {
logger.info(`got signal ${signal}, removing ${srf.locals.localSipAddress} from set ${setName}`);
removeFromSet(setName, srf.locals.localSipAddress);
}
removeFromSet(FS_UUID_SET_NAME, srf.locals.fsUUID);
if (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) { if (process.env.JAMBONZ_CLEANUP_INTERVAL_MINS) {

View File

@@ -48,5 +48,5 @@
"ens posarem en contacto", "ens posarem en contacto",
"ara no estem disponibles", "ara no estem disponibles",
"no hi som" "no hi som"
], ]
} }

View File

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

View File

@@ -2,7 +2,7 @@ const router = require('express').Router();
const CallInfo = require('../../session/call-info'); const CallInfo = require('../../session/call-info');
const {CallDirection} = require('../../utils/constants'); const {CallDirection} = require('../../utils/constants');
const SmsSession = require('../../session/sms-call-session'); const SmsSession = require('../../session/sms-call-session');
const normalizeJambones = require('../../utils/normalize-jambones'); const { normalizeJambones } = require('@jambonz/verb-specifications');
const makeTask = require('../../tasks/make_task'); const makeTask = require('../../tasks/make_task');
router.post('/:sid', async(req, res) => { router.post('/:sid', async(req, res) => {

View File

@@ -4,7 +4,7 @@ const WsRequestor = require('../../utils/ws-requestor');
const CallInfo = require('../../session/call-info'); const CallInfo = require('../../session/call-info');
const {CallDirection} = require('../../utils/constants'); const {CallDirection} = require('../../utils/constants');
const SmsSession = require('../../session/sms-call-session'); const SmsSession = require('../../session/sms-call-session');
const normalizeJambones = require('../../utils/normalize-jambones'); const { normalizeJambones } = require('@jambonz/verb-specifications');
const {TaskPreconditions} = require('../../utils/constants'); const {TaskPreconditions} = require('../../utils/constants');
const makeTask = require('../../tasks/make_task'); const makeTask = require('../../tasks/make_task');
@@ -34,6 +34,7 @@ router.post('/:partner', async(req, res) => {
carrier: req.params.partner, carrier: req.params.partner,
messageSid: app.messageSid, messageSid: app.messageSid,
accountSid: app.accountSid, accountSid: app.accountSid,
serviceProviderSid: account.service_provider_sid,
applicationSid: app.applicationSid, applicationSid: app.applicationSid,
from: req.body.from, from: req.body.from,
to: req.body.to, to: req.body.to,

View File

@@ -1,11 +1,12 @@
const { v4: uuidv4 } = require('uuid'); const uuidv4 = require('uuid-random');
const {CallDirection} = require('./utils/constants'); const {CallDirection, AllowedSipRecVerbs} = require('./utils/constants');
const {parseSiprecPayload} = require('./utils/siprec-utils');
const CallInfo = require('./session/call-info'); const CallInfo = require('./session/call-info');
const HttpRequestor = require('./utils/http-requestor'); const HttpRequestor = require('./utils/http-requestor');
const WsRequestor = require('./utils/ws-requestor'); const WsRequestor = require('./utils/ws-requestor');
const makeTask = require('./tasks/make_task'); const makeTask = require('./tasks/make_task');
const parseUri = require('drachtio-srf').parseUri; const parseUri = require('drachtio-srf').parseUri;
const normalizeJambones = require('./utils/normalize-jambones'); const { normalizeJambones } = require('@jambonz/verb-specifications');
const dbUtils = require('./utils/db-utils'); const dbUtils = require('./utils/db-utils');
const RootSpan = require('./utils/call-tracer'); const RootSpan = require('./utils/call-tracer');
const listTaskNames = require('./utils/summarize-tasks'); const listTaskNames = require('./utils/summarize-tasks');
@@ -18,16 +19,26 @@ module.exports = function(srf, logger) {
lookupAppByRealm, lookupAppByRealm,
lookupAppByTeamsTenant lookupAppByTeamsTenant
} = srf.locals.dbHelpers; } = srf.locals.dbHelpers;
const {
writeAlerts,
AlertType
} = srf.locals;
const {lookupAccountDetails} = dbUtils(logger, srf); const {lookupAccountDetails} = dbUtils(logger, srf);
function initLocals(req, res, next) { function initLocals(req, res, next) {
const callId = req.get('Call-ID');
logger.info({
callId,
callingNumber: req.callingNumber,
calledNumber: req.calledNumber
}, 'new incoming call');
if (!req.has('X-Account-Sid')) { if (!req.has('X-Account-Sid')) {
logger.info('getAccountDetails - rejecting call due to missing X-Account-Sid header'); logger.info('getAccountDetails - rejecting call due to missing X-Account-Sid header');
return res.send(500); return res.send(500);
} }
const callSid = req.has('X-Retain-Call-Sid') ? req.get('X-Retain-Call-Sid') : uuidv4(); const callSid = req.has('X-Retain-Call-Sid') ? req.get('X-Retain-Call-Sid') : uuidv4();
const account_sid = req.get('X-Account-Sid'); 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')) { if (req.has('X-Application-Sid')) {
const application_sid = req.get('X-Application-Sid'); const application_sid = req.get('X-Application-Sid');
logger.debug(`got application from X-Application-Sid header: ${application_sid}`); logger.debug(`got application from X-Application-Sid header: ${application_sid}`);
@@ -40,7 +51,7 @@ module.exports = function(srf, logger) {
} }
function createRootSpan(req, res, next) { function createRootSpan(req, res, next) {
const {callSid, account_sid} = req.locals; const {callId, callSid, account_sid} = req.locals;
const rootSpan = new RootSpan('incoming-call', req); const rootSpan = new RootSpan('incoming-call', req);
const traceId = rootSpan.traceId; const traceId = rootSpan.traceId;
@@ -48,7 +59,7 @@ module.exports = function(srf, logger) {
...req.locals, ...req.locals,
traceId, traceId,
logger: logger.child({ logger: logger.child({
callId: req.get('Call-ID'), callId,
callSid, callSid,
accountSid: account_sid, accountSid: account_sid,
callingNumber: req.callingNumber, callingNumber: req.callingNumber,
@@ -73,6 +84,35 @@ module.exports = function(srf, logger) {
next(); next();
} }
const handleSipRec = async(req, res, next) => {
if (Array.isArray(req.payload) && req.payload.length > 1) {
const {callId, logger} = req.locals;
logger.debug({payload: req.payload}, 'handling siprec call');
try {
const sdp = req.payload
.find((p) => p.type === 'application/sdp')
.content;
const {sdp1, sdp2, ...metadata} = await parseSiprecPayload(req, logger);
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 * retrieve account information for the incoming call
*/ */
@@ -82,6 +122,7 @@ module.exports = function(srf, logger) {
const {span} = rootSpan.startChildSpan('lookupAccountDetails'); const {span} = rootSpan.startChildSpan('lookupAccountDetails');
try { try {
req.locals.accountInfo = await lookupAccountDetails(account_sid); req.locals.accountInfo = await lookupAccountDetails(account_sid);
req.locals.service_provider_sid = req.locals.accountInfo?.account?.service_provider_sid;
span.end(); span.end();
if (!req.locals.accountInfo.account.is_active) { if (!req.locals.accountInfo.account.is_active) {
logger.info(`Account is inactive or suspended ${account_sid}`); logger.info(`Account is inactive or suspended ${account_sid}`);
@@ -101,7 +142,10 @@ module.exports = function(srf, logger) {
* Within the system, we deal with E.164 numbers _without_ the leading '+ * Within the system, we deal with E.164 numbers _without_ the leading '+
*/ */
function normalizeNumbers(req, res, next) { function normalizeNumbers(req, res, next) {
const logger = req.locals.logger; const {logger, siprec} = req.locals;
if (siprec) return next();
Object.assign(req.locals, { Object.assign(req.locals, {
calledNumber: req.calledNumber, calledNumber: req.calledNumber,
callingNumber: req.callingNumber callingNumber: req.callingNumber
@@ -122,8 +166,7 @@ module.exports = function(srf, logger) {
* Given the dialed DID/phone number, retrieve the application to invoke * Given the dialed DID/phone number, retrieve the application to invoke
*/ */
async function retrieveApplication(req, res, next) { async function retrieveApplication(req, res, next) {
const logger = req.locals.logger; const {logger, accountInfo, account_sid, rootSpan} = req.locals;
const {accountInfo, account_sid, rootSpan} = req.locals;
const {span} = rootSpan.startChildSpan('lookupApplication'); const {span} = rootSpan.startChildSpan('lookupApplication');
try { try {
let app; let app;
@@ -186,29 +229,32 @@ module.exports = function(srf, logger) {
* create a requestor that we will use for all http requests we make during the call. * create a requestor that we will use for all http requests we make during the call.
* also create a notifier for call status events (if not needed, its a no-op). * also create a notifier for call status events (if not needed, its a no-op).
*/ */
/* 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 || if ('WS' === app.call_hook?.method ||
app.call_hook?.url.startsWith('ws://') || app.call_hook?.url.startsWith('wss://')) { 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) ; app2.requestor = new WsRequestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret) ;
app.notifier = app.requestor; app2.notifier = app.requestor;
app.call_hook.method = 'WS'; app2.call_hook.method = 'WS';
} }
else { else {
app.requestor = new HttpRequestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret); app2.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, if (app.call_status_hook) app2.notifier = new HttpRequestor(logger, account_sid, app.call_status_hook,
accountInfo.account.webhook_secret); accountInfo.account.webhook_secret);
else app.notifier = {request: () => {}}; else app2.notifier = {request: () => {}};
} }
req.locals.application = app; req.locals.application = app2;
const obj = Object.assign({}, app);
delete obj.requestor;
delete obj.notifier;
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const {call_hook, call_status_hook, ...appInfo} = obj; // mask sensitive data like user/pass on webhook const {call_hook, call_status_hook, ...appInfo} = app; // mask sensitive data like user/pass on webhook
logger.info({app: appInfo}, `retrieved application for incoming call to ${req.locals.calledNumber}`); // 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.locals.callInfo = new CallInfo({
req, req,
app, app: app2,
direction: CallDirection.Inbound, direction: CallDirection.Inbound,
traceId: rootSpan.traceId traceId: rootSpan.traceId
}); });
@@ -225,46 +271,71 @@ module.exports = function(srf, logger) {
*/ */
async function invokeWebCallback(req, res, next) { async function invokeWebCallback(req, res, next) {
const logger = req.locals.logger; const logger = req.locals.logger;
const {rootSpan, application:app} = req.locals; const {rootSpan, siprec, application:app} = req.locals;
let span; let span;
try { try {
if (app.tasks) { if (app.tasks && !process.env.JAMBONES_MYSQL_REFRESH_TTL) {
app.tasks = normalizeJambones(logger, app.tasks).map((tdata) => makeTask(logger, tdata)); app.tasks = normalizeJambones(logger, app.tasks).map((tdata) => makeTask(logger, tdata));
if (0 === app.tasks.length) throw new Error('no application provided'); if (0 === app.tasks.length) throw new Error('no application provided');
return next(); return next();
} }
/* retrieve the application to execute for this inbound call */ /* retrieve the application to execute for this inbound call */
const params = Object.assign(['POST', 'WS'].includes(app.call_hook.method) ? {sip: req.msg} : {}, let json;
req.locals.callInfo, { if (app.app_json) {
defaults: { json = JSON.parse(app.app_json);
synthesizer: { } else {
vendor: app.speech_synthesis_vendor, const params = Object.assign(['POST', 'WS'].includes(app.call_hook.method) ? { sip: req.msg } : {},
language: app.speech_synthesis_language, req.locals.callInfo,
voice: app.speech_synthesis_voice { service_provider_sid: req.locals.service_provider_sid },
}, {
recognizer: { defaults: {
vendor: app.speech_recognizer_vendor, synthesizer: {
language: app.speech_recognizer_language vendor: app.speech_synthesis_vendor,
language: app.speech_synthesis_language,
voice: app.speech_synthesis_voice
},
recognizer: {
vendor: app.speech_recognizer_vendor,
language: app.speech_recognizer_language
}
} }
} });
}); logger.debug({ params }, 'sending initial webhook');
logger.debug({params}, 'sending initial webhook'); const obj = rootSpan.startChildSpan('performAppWebhook');
const obj = rootSpan.startChildSpan('performAppWebhook'); span = obj.span;
span = obj.span; const b3 = rootSpan.getTracingPropagation();
const b3 = rootSpan.getTracingPropagation(); const httpHeaders = b3 && { b3 };
const httpHeaders = b3 && {b3}; json = await app.requestor.request('session:new', app.call_hook, params, httpHeaders);
const json = await app.requestor.request('session:new', app.call_hook, params, httpHeaders); }
app.tasks = normalizeJambones(logger, json).map((tdata) => makeTask(logger, tdata)); app.tasks = normalizeJambones(logger, json).map((tdata) => makeTask(logger, tdata));
span.setAttributes({ span?.setAttributes({
'http.statusCode': 200, 'http.statusCode': 200,
'app.tasks': listTaskNames(app.tasks) 'app.tasks': listTaskNames(app.tasks)
}); });
span.end(); span?.end();
if (0 === app.tasks.length) throw new Error('no application provided'); if (0 === app.tasks.length) throw new Error('no application provided');
if (siprec) {
const tasks = app.tasks.filter((t) => AllowedSipRecVerbs.includes(t.name));
if (0 === tasks.length) {
logger.info({tasks: app.tasks}, 'no valid verbs in app found for an incoming siprec call');
throw new Error('invalid verbs for incoming siprec call');
}
if (tasks.length < app.tasks.length) {
logger.info('removing verbs that are not allowed for incoming siprec call');
app.tasks = tasks;
}
}
next(); next();
} catch (err) { } catch (err) {
span?.setAttributes({webhookStatus: err.statusCode}); span?.setAttributes({webhookStatus: err.statusCode});
span?.end(); 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}`); logger.info({err}, `Error retrieving or parsing application: ${err?.message}`);
res.send(480, {headers: {'X-Reason': err?.message || 'unknown'}}); res.send(480, {headers: {'X-Reason': err?.message || 'unknown'}});
app.requestor.close(); app.requestor.close();
@@ -274,6 +345,7 @@ module.exports = function(srf, logger) {
return { return {
initLocals, initLocals,
createRootSpan, createRootSpan,
handleSipRec,
getAccountDetails, getAccountDetails,
normalizeNumbers, normalizeNumbers,
retrieveApplication, retrieveApplication,

View File

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

View File

@@ -6,13 +6,14 @@ const {
CallStatus, CallStatus,
TaskName, TaskName,
KillReason, KillReason,
RecordState RecordState,
AllowedSipRecVerbs
} = require('../utils/constants'); } = require('../utils/constants');
const moment = require('moment'); const moment = require('moment');
const assert = require('assert'); const assert = require('assert');
const sessionTracker = require('./session-tracker'); const sessionTracker = require('./session-tracker');
const makeTask = require('../tasks/make_task'); const makeTask = require('../tasks/make_task');
const normalizeJambones = require('../utils/normalize-jambones'); const { normalizeJambones } = require('@jambonz/verb-specifications');
const listTaskNames = require('../utils/summarize-tasks'); const listTaskNames = require('../utils/summarize-tasks');
const HttpRequestor = require('../utils/http-requestor'); const HttpRequestor = require('../utils/http-requestor');
const WsRequestor = require('../utils/ws-requestor'); const WsRequestor = require('../utils/ws-requestor');
@@ -62,6 +63,7 @@ class CallSession extends Emitter {
assert(rootSpan); assert(rootSpan);
this._recordState = RecordState.RecordingOff; this._recordState = RecordState.RecordingOff;
this._notifyEvents = false;
this.tmpFiles = new Set(); this.tmpFiles = new Set();
@@ -80,8 +82,18 @@ class CallSession extends Emitter {
this._pool = srf.locals.dbHelpers.pool; this._pool = srf.locals.dbHelpers.pool;
const handover = (newRequestor) => {
this.logger.info(`handover to new base url ${newRequestor.url}`);
this.requestor.removeAllListeners();
this.application.requestor = newRequestor;
this.requestor.on('command', this._onCommand.bind(this));
this.requestor.on('connection-dropped', this._onWsConnectionDropped.bind(this));
this.requestor.on('handover', handover.bind(this));
};
this.requestor.on('command', this._onCommand.bind(this)); this.requestor.on('command', this._onCommand.bind(this));
this.requestor.on('connection-dropped', this._onWsConnectionDropped.bind(this)); this.requestor.on('connection-dropped', this._onWsConnectionDropped.bind(this));
this.requestor.on('handover', handover.bind(this));
} }
/** /**
@@ -102,6 +114,10 @@ class CallSession extends Emitter {
return this.callInfo.applicationSid; return this.callInfo.applicationSid;
} }
get callStatus() {
return this.callInfo.callStatus;
}
/** /**
* SIP call-id for the call * SIP call-id for the call
*/ */
@@ -228,6 +244,13 @@ class CallSession extends Emitter {
return this.constructor.name === 'ConfirmCallSession'; 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 * returns true if this session is a SmsCallSession
*/ */
@@ -247,12 +270,56 @@ class CallSession extends Emitter {
return this.backgroundGatherTask; return this.backgroundGatherTask;
} }
get isListenEnabled() {
return this.backgroundListenTask;
}
get b3() { get b3() {
return this.rootSpan?.getTracingPropagation(); return this.rootSpan?.getTracingPropagation();
} }
get recordState() { return this._recordState; } get recordState() { return this._recordState; }
get notifyEvents() { return this._notifyEvents; }
set notifyEvents(notify) { this._notifyEvents = !!notify; }
set globalSttHints({hints, hintsBoost}) {
this._globalSttHints = {hints, hintsBoost};
}
get hasGlobalSttHints() {
const {hints = []} = this._globalSttHints || {};
return hints.length > 0;
}
get globalSttHints() {
return this._globalSttHints;
}
set altLanguages(langs) {
this._globalAltLanguages = langs;
}
get hasAltLanguages() {
return Array.isArray(this._globalAltLanguages);
}
get altLanguages() {
return this._globalAltLanguages;
}
set globalSttPunctuation(punctuate) {
this._globalSttPunctuation = punctuate;
}
get globalSttPunctuation() {
return this._globalSttPunctuation;
}
hasGlobalSttPunctuation() {
return this._globalSttPunctuation !== undefined;
}
async notifyRecordOptions(opts) { async notifyRecordOptions(opts) {
const {action} = opts; const {action} = opts;
this.logger.debug({opts}, 'CallSession:notifyRecordOptions'); this.logger.debug({opts}, 'CallSession:notifyRecordOptions');
@@ -398,15 +465,64 @@ class CallSession extends Emitter {
} }
} }
async startBackgroundListen(opts) {
if (this.isListenEnabled) {
this.logger.info('CallSession:startBackgroundListen - listen is already enabled, ignoring request');
return;
}
try {
this.logger.debug({opts}, 'CallSession:startBackgroundListen');
const t = normalizeJambones(this.logger, [opts]);
this.backgroundListenTask = makeTask(this.logger, t[0]);
const resources = await this._evaluatePreconditions(this.backgroundListenTask);
const {span, ctx} = this.rootSpan.startChildSpan(`background-gather:${this.backgroundListenTask.summary}`);
this.backgroundListenTask.span = span;
this.backgroundListenTask.ctx = ctx;
this.backgroundListenTask.exec(this, resources)
.then(() => {
this.logger.info('CallSession:startBackgroundListen: listen completed');
this.backgroundListenTask && this.backgroundListenTask.removeAllListeners();
this.backgroundListenTask && this.backgroundListenTask.span.end();
this.backgroundListenTask = null;
return;
})
.catch((err) => {
this.logger.info({err}, 'CallSession:startBackgroundListen: listen threw error');
this.backgroundListenTask && this.backgroundListenTask.removeAllListeners();
this.backgroundListenTask && this.backgroundListenTask.span.end();
this.backgroundListenTask = null;
});
} catch (err) {
this.logger.info({err, opts}, 'CallSession:startBackgroundListen - Error creating listen task');
}
}
async stopBackgroundListen() {
try {
if (this.backgroundListenTask) {
this.backgroundListenTask.removeAllListeners();
this.backgroundListenTask.kill().catch(() => {});
}
} catch (err) {
this.logger.info({err}, 'CallSession:stopBackgroundListen - Error stopping listen task');
}
this.backgroundListenTask = null;
}
async enableBotMode(gather, autoEnable) { async enableBotMode(gather, autoEnable) {
try { 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]); const t = normalizeJambones(this.logger, [gather]);
this.backgroundGatherTask = makeTask(this.logger, t[0]); this.backgroundGatherTask = makeTask(this.logger, t[0]);
this._bargeInEnabled = true;
this.backgroundGatherTask this.backgroundGatherTask
.once('dtmf', this._clearTasks.bind(this)) .once('dtmf', this._clearTasks.bind(this, this.backgroundGatherTask))
.once('vad', this._clearTasks.bind(this)) .once('vad', this._clearTasks.bind(this, this.backgroundGatherTask))
.once('transcription', this._clearTasks.bind(this)) .once('transcription', this._clearTasks.bind(this, this.backgroundGatherTask))
.once('timeout', this._clearTasks.bind(this)); .once('timeout', this._clearTasks.bind(this, this.backgroundGatherTask));
this.logger.info({gather}, 'CallSession:enableBotMode - starting background gather'); this.logger.info({gather}, 'CallSession:enableBotMode - starting background gather');
const resources = await this._evaluatePreconditions(this.backgroundGatherTask); const resources = await this._evaluatePreconditions(this.backgroundGatherTask);
const {span, ctx} = this.rootSpan.startChildSpan(`background-gather:${this.backgroundGatherTask.summary}`); const {span, ctx} = this.rootSpan.startChildSpan(`background-gather:${this.backgroundGatherTask.summary}`);
@@ -418,7 +534,7 @@ class CallSession extends Emitter {
this.backgroundGatherTask && this.backgroundGatherTask.removeAllListeners(); this.backgroundGatherTask && this.backgroundGatherTask.removeAllListeners();
this.backgroundGatherTask && this.backgroundGatherTask.span.end(); this.backgroundGatherTask && this.backgroundGatherTask.span.end();
this.backgroundGatherTask = null; 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'); this.logger.info('CallSession:enableBotMode: restarting background gather');
setImmediate(() => this.enableBotMode(gather, true)); setImmediate(() => this.enableBotMode(gather, true));
} }
@@ -435,6 +551,7 @@ class CallSession extends Emitter {
} }
} }
disableBotMode() { disableBotMode() {
this._bargeInEnabled = false;
if (this.backgroundGatherTask) { if (this.backgroundGatherTask) {
try { try {
this.backgroundGatherTask.removeAllListeners(); this.backgroundGatherTask.removeAllListeners();
@@ -502,7 +619,11 @@ class CallSession extends Emitter {
return { return {
speech_credential_sid: credential.speech_credential_sid, speech_credential_sid: credential.speech_credential_sid,
api_key: credential.api_key, 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) { else if ('wellsaid' === vendor) {
@@ -511,6 +632,42 @@ class CallSession extends Emitter {
api_key: credential.api_key api_key: credential.api_key
}; };
} }
else if ('nuance' === vendor) {
return {
speech_credential_sid: credential.speech_credential_sid,
client_id: credential.client_id,
secret: credential.secret
};
}
else if ('deepgram' === vendor) {
return {
speech_credential_sid: credential.speech_credential_sid,
api_key: credential.api_key
};
}
else if ('soniox' === vendor) {
return {
speech_credential_sid: credential.speech_credential_sid,
api_key: credential.api_key
};
}
else if ('ibm' === vendor) {
return {
speech_credential_sid: credential.speech_credential_sid,
tts_api_key: credential.tts_api_key,
tts_region: credential.tts_region,
stt_api_key: credential.stt_api_key,
stt_region: credential.stt_region
};
}
else if (vendor.startsWith('custom:')) {
return {
speech_credential_sid: credential.speech_credential_sid,
auth_token: credential.auth_token,
custom_stt_url: credential.custom_stt_url,
custom_tts_url: credential.custom_tts_url
};
}
} }
else { else {
writeAlerts({ writeAlerts({
@@ -535,15 +692,22 @@ class CallSession extends Emitter {
const stackNum = this.stackIdx; const stackNum = this.stackIdx;
const task = this.tasks.shift(); const task = this.tasks.shift();
this.logger.info(`CallSession:exec starting task #${stackNum}:${taskNum}: ${task.name}`); this.logger.info(`CallSession:exec starting task #${stackNum}:${taskNum}: ${task.name}`);
this._notifyTaskStatus(task, {event: 'starting'});
try { try {
const resources = await this._evaluatePreconditions(task); const resources = await this._evaluatePreconditions(task);
let skip = false;
this.currentTask = task; this.currentTask = task;
if (TaskName.Gather === task.name && this.isBotModeEnabled) { if (TaskName.Gather === task.name && this.isBotModeEnabled) {
const timeout = task.timeout; if (this.backgroundGatherTask.updateTaskInProgress(task) !== false) {
this.logger.info(`CallSession:exec skipping #${stackNum}:${taskNum}: ${task.name}`); this.logger.info(`CallSession:exec skipping #${stackNum}:${taskNum}: ${task.name}`);
this.backgroundGatherTask.updateTimeout(timeout); 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}`); const {span, ctx} = this.rootSpan.startChildSpan(`verb:${task.summary}`);
task.span = span; task.span = span;
task.ctx = ctx; task.ctx = ctx;
@@ -552,6 +716,7 @@ class CallSession extends Emitter {
} }
this.currentTask = null; this.currentTask = null;
this.logger.info(`CallSession:exec completed task #${stackNum}:${taskNum}: ${task.name}`); this.logger.info(`CallSession:exec completed task #${stackNum}:${taskNum}: ${task.name}`);
this._notifyTaskStatus(task, {event: 'finished'});
} catch (err) { } catch (err) {
task.span?.end(); task.span?.end();
this.currentTask = null; this.currentTask = null;
@@ -564,7 +729,7 @@ class CallSession extends Emitter {
} }
} }
if (0 === this.tasks.length && this.hasStableDialog && this.requestor instanceof WsRequestor) { if (0 === this.tasks.length && this.requestor instanceof WsRequestor && !this.callGone) {
let span; let span;
try { try {
const {span} = this.rootSpan.startChildSpan('waiting for commands'); const {span} = this.rootSpan.startChildSpan('waiting for commands');
@@ -575,7 +740,7 @@ class CallSession extends Emitter {
'async.request.command': command 'async.request.command': command
}); });
span.end(); span.end();
if (!this.hasStableDialog || this.callGone) break; if (this.callGone) break;
} catch (err) { } catch (err) {
span.end(); span.end();
this.logger.info(err, 'CallSession:exec - error waiting for new commands'); this.logger.info(err, 'CallSession:exec - error waiting for new commands');
@@ -591,12 +756,12 @@ class CallSession extends Emitter {
this._onTasksDone(); this._onTasksDone();
this._clearResources(); this._clearResources();
if (!this.isConfirmCallSession && !this.isSmsCallSession) sessionTracker.remove(this.callSid); if (!this.isConfirmCallSession && !this.isSmsCallSession) sessionTracker.remove(this.callSid);
} }
trackTmpFile(path) { trackTmpFile(path) {
// TODO: don't add if its already in the list (should we make it a set?) // TODO: don't add if its already in the list (should we make it a set?)
this.logger.debug(`adding tmp file to track ${path}`);
this.tmpFiles.add(path); this.tmpFiles.add(path);
} }
@@ -908,6 +1073,20 @@ class CallSession extends Emitter {
this.logger.debug('CallSession:replaceApplication - ignoring because call is gone'); this.logger.debug('CallSession:replaceApplication - ignoring because call is gone');
return; 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.tasks = tasks;
this.taskIdx = 0; this.taskIdx = 0;
this.stackIdx++; this.stackIdx++;
@@ -919,14 +1098,32 @@ class CallSession extends Emitter {
} }
} }
kill() { kill(onBackgroundGatherBargein = false) {
if (this.isConfirmCallSession) this.logger.debug('CallSession:kill (ConfirmSession)'); if (this.isConfirmCallSession) this.logger.debug('CallSession:kill (ConfirmSession)');
else this.logger.info('CallSession:kill'); else this.logger.info('CallSession:kill');
if (this.currentTask) { if (this.currentTask) {
this.currentTask.kill(this); this.currentTask.kill(this);
this.currentTask = null; 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; this.taskIdx = 0;
} }
@@ -939,14 +1136,14 @@ class CallSession extends Emitter {
_injectTasks(newTasks) { _injectTasks(newTasks) {
const gatherPos = this.tasks.map((t) => t.name).indexOf(TaskName.Gather); const gatherPos = this.tasks.map((t) => t.name).indexOf(TaskName.Gather);
const currentlyExecutingGather = this.currentTask?.name === TaskName.Gather; const currentlyExecutingGather = this.currentTask?.name === TaskName.Gather;
/*
this.logger.debug({ this.logger.debug({
currentTaskList: listTaskNames(this.tasks), currentTaskList: listTaskNames(this.tasks),
newContent: listTaskNames(newTasks), newContent: listTaskNames(newTasks),
currentlyExecutingGather, currentlyExecutingGather,
gatherPos gatherPos
}, 'CallSession:_injectTasks - starting'); }, 'CallSession:_injectTasks - starting');
*/
const killGather = () => { const killGather = () => {
this.logger.debug('CallSession:_injectTasks - killing current gather because we have new content'); this.logger.debug('CallSession:_injectTasks - killing current gather because we have new content');
this.currentTask.kill(this); this.currentTask.kill(this);
@@ -955,10 +1152,11 @@ class CallSession extends Emitter {
if (-1 === gatherPos) { if (-1 === gatherPos) {
/* no gather in the stack simply append tasks */ /* no gather in the stack simply append tasks */
this.tasks.push(...newTasks); this.tasks.push(...newTasks);
/*
this.logger.debug({ this.logger.debug({
updatedTaskList: listTaskNames(this.tasks) updatedTaskList: listTaskNames(this.tasks)
}, 'CallSession:_injectTasks - completed (simple append)'); }, 'CallSession:_injectTasks - completed (simple append)');
*/
/* we do need to kill the current gather if we are executing one */ /* we do need to kill the current gather if we are executing one */
if (currentlyExecutingGather) killGather(); if (currentlyExecutingGather) killGather();
return; return;
@@ -986,12 +1184,10 @@ class CallSession extends Emitter {
this.replaceApplication(t); this.replaceApplication(t);
} }
else if (process.env.JAMBONES_INJECT_CONTENT) { else if (process.env.JAMBONES_INJECT_CONTENT) {
this.logger.debug({tasks: listTaskNames(t)}, 'CallSession:_onCommand - queueing tasks (injecting content)');
this._injectTasks(t); this._injectTasks(t);
this.logger.info({tasks: listTaskNames(this.tasks)}, 'CallSession:_onCommand - updated task list'); this.logger.info({tasks: listTaskNames(this.tasks)}, 'CallSession:_onCommand - updated task list');
} }
else { else {
this.logger.debug({tasks: listTaskNames(t)}, 'CallSession:_onCommand - queueing tasks');
this.tasks.push(...t); this.tasks.push(...t);
this.logger.info({tasks: listTaskNames(this.tasks)}, 'CallSession:_onCommand - updated task list'); this.logger.info({tasks: listTaskNames(this.tasks)}, 'CallSession:_onCommand - updated task list');
} }
@@ -1035,7 +1231,7 @@ class CallSession extends Emitter {
this.logger.info(`CallSession:_onCommand - invalid command ${command}`); this.logger.info(`CallSession:_onCommand - invalid command ${command}`);
} }
if (this.wakeupResolver) { if (this.wakeupResolver) {
this.logger.debug({resolution}, 'CallSession:_onCommand - got commands, waking up..'); //this.logger.debug({resolution}, 'CallSession:_onCommand - got commands, waking up..');
this.wakeupResolver(resolution); this.wakeupResolver(resolution);
this.wakeupResolver = null; this.wakeupResolver = null;
} }
@@ -1075,28 +1271,36 @@ class CallSession extends Emitter {
* @param {Task} task - task to be executed * @param {Task} task - task to be executed
*/ */
async _evalEndpointPrecondition(task) { async _evalEndpointPrecondition(task) {
this.logger.debug('CallSession:_evalEndpointPrecondition');
if (this.callGone) new Error(`${BADPRECONDITIONS}: call gone`); if (this.callGone) new Error(`${BADPRECONDITIONS}: call gone`);
if (this.ep) { 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 // we are going from an early media connection to answer
await this.propagateAnswer(); await this.propagateAnswer();
return this.ep; return {
...resources,
...(this.isSipRecCallSession && {ep2: this.ep2})
};
} }
// need to allocate an endpoint // need to allocate an endpoint
try { try {
if (!this.ms) this.ms = this.getMS(); 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; //ep.cs = this;
this.ep = ep; this.ep = ep;
ep.set({
hangup_after_bridge: false,
park_after_bridge: true
}).catch((err) => this.logger.error({err}, 'Error setting park_after_bridge'));
this.logger.debug(`allocated endpoint ${ep.uuid}`); this.logger.debug(`allocated endpoint ${ep.uuid}`);
this.ep.on('destroy', () => { this.ep.on('destroy', () => {
@@ -1106,7 +1310,7 @@ class CallSession extends Emitter {
if (this.direction === CallDirection.Inbound) { if (this.direction === CallDirection.Inbound) {
if (task.earlyMedia && !this.req.finalResponseSent) { if (task.earlyMedia && !this.req.finalResponseSent) {
this.res.send(183, {body: ep.local.sdp}); this.res.send(183, {body: ep.local.sdp});
return ep; return {ep};
} }
this.logger.debug('propogating answer'); this.logger.debug('propogating answer');
await this.propagateAnswer(); await this.propagateAnswer();
@@ -1115,10 +1319,11 @@ class CallSession extends Emitter {
// outbound call TODO // outbound call TODO
} }
return ep; return {ep};
} catch (err) { } catch (err) {
if (err === CALLER_CANCELLED_ERR_MSG) { if (err === CALLER_CANCELLED_ERR_MSG) {
this.logger.error(err, 'caller canceled quickly before we could respond, ending call'); this.logger.error(err, 'caller canceled quickly before we could respond, ending call');
this.callInfo.callTerminationBy = 'caller';
this._notifyCallStatusChange({ this._notifyCallStatusChange({
callStatus: CallStatus.NoAnswer, callStatus: CallStatus.NoAnswer,
sipStatus: 487, sipStatus: 487,
@@ -1140,7 +1345,7 @@ class CallSession extends Emitter {
_evalStableCallPrecondition(task) { _evalStableCallPrecondition(task) {
if (this.callGone) throw new Error(`${BADPRECONDITIONS}: call gone`); if (this.callGone) throw new Error(`${BADPRECONDITIONS}: call gone`);
if (!this.dlg) throw new Error(`${BADPRECONDITIONS}: call was not answered`); if (!this.dlg) throw new Error(`${BADPRECONDITIONS}: call was not answered`);
return this.dlg; return {dlg: this.dlg};
} }
/** /**
@@ -1168,7 +1373,6 @@ class CallSession extends Emitter {
return; return;
} }
this.ep = await this.ms.createEndpoint({remoteSdp: this.dlg.remote.sdp}); this.ep = await this.ms.createEndpoint({remoteSdp: this.dlg.remote.sdp});
await this.ep.set('hangup_after_bridge', false);
await this.dlg.modify(this.ep.local.sdp); await this.dlg.modify(this.ep.local.sdp);
this.logger.debug('CallSession:replaceEndpoint completed'); this.logger.debug('CallSession:replaceEndpoint completed');
@@ -1179,7 +1383,7 @@ class CallSession extends Emitter {
* Hang up the call and free the media endpoint * Hang up the call and free the media endpoint
*/ */
_clearResources() { _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(); if (resource && resource.connected) resource.destroy();
} }
this.dlg = null; this.dlg = null;
@@ -1196,6 +1400,7 @@ class CallSession extends Emitter {
} }
this.tmpFiles.clear(); this.tmpFiles.clear();
this.requestor && this.requestor.close(); this.requestor && this.requestor.close();
this.notifier && this.notifier.close();
this.rootSpan && this.rootSpan.end(); this.rootSpan && this.rootSpan.end();
} }
@@ -1233,7 +1438,8 @@ class CallSession extends Emitter {
this.dlg = await this.srf.createUAS(this.req, this.res, { this.dlg = await this.srf.createUAS(this.req, this.res, {
headers: { headers: {
'X-Trace-ID': this.req.locals.traceId, '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 localSdp: this.ep.local.sdp
}); });
@@ -1256,9 +1462,15 @@ class CallSession extends Emitter {
async _onReinvite(req, res) { async _onReinvite(req, res) {
try { try {
if (this.ep) { if (this.ep) {
const newSdp = await this.ep.modify(req.body); if (this.isSipRecCallSession) {
res.send(200, {body: newSdp}); this.logger.info('handling reINVITE for siprec call');
this.logger.info({offer: req.body, answer: newSdp}, 'handling reINVITE'); res.send(200, {body: this.ep.local.sdp});
}
else {
const newSdp = await this.ep.modify(req.body);
res.send(200, {body: newSdp});
this.logger.info({offer: req.body, answer: newSdp}, 'handling reINVITE');
}
} }
else if (this.currentTask && this.currentTask.name === TaskName.Dial) { else if (this.currentTask && this.currentTask.name === TaskName.Dial) {
this.logger.info('handling reINVITE after media has been released'); this.logger.info('handling reINVITE after media has been released');
@@ -1304,7 +1516,6 @@ class CallSession extends Emitter {
} }
if (!this.ep) { if (!this.ep) {
this.ep = await this.ms.createEndpoint({remoteSdp: this.req.body}); this.ep = await this.ms.createEndpoint({remoteSdp: this.req.body});
await this.ep.set('hangup_after_bridge', false);
} }
return {ms: this.ms, ep: this.ep}; return {ms: this.ms, ep: this.ep};
} }
@@ -1387,7 +1598,8 @@ class CallSession extends Emitter {
headers: { headers: {
'Refer-To': referTo, 'Refer-To': referTo,
'Referred-By': `sip:${this.srf.locals.localSipAddress}`, '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)) { if ([200, 202].includes(res.status)) {
@@ -1425,8 +1637,9 @@ class CallSession extends Emitter {
dlg.connected = false; dlg.connected = false;
dlg.destroy = origDestroy; dlg.destroy = origDestroy;
const duration = moment().diff(this.dlg.connectTime, 'seconds'); const duration = moment().diff(this.dlg.connectTime, 'seconds');
this.callInfo.callTerminationBy = 'jambonz';
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration}); 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'}); this.rootSpan.setAttributes({'call.termination': 'hangup by jambonz'});
origDestroy().catch((err) => this.logger.info({err}, 'CallSession - error destroying dialog')); origDestroy().catch((err) => this.logger.info({err}, 'CallSession - error destroying dialog'));
if (this.wakeupResolver) { if (this.wakeupResolver) {
@@ -1475,7 +1688,7 @@ class CallSession extends Emitter {
* @param {number} sipStatus - current sip status * @param {number} sipStatus - current sip status
* @param {number} [duration] - duration of a completed call, in seconds * @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; if (this.callMoved) return;
/* race condition: we hang up at the same time as the caller */ /* race condition: we hang up at the same time as the caller */
@@ -1495,7 +1708,7 @@ class CallSession extends Emitter {
try { try {
const b3 = this.b3; const b3 = this.b3;
const httpHeaders = b3 && {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(); span.end();
} catch (err) { } catch (err) {
span.end(); span.end();
@@ -1508,6 +1721,25 @@ class CallSession extends Emitter {
.catch((err) => this.logger.error(err, 'redis error')); .catch((err) => this.logger.error(err, 'redis error'));
} }
/**
* notifyTaskError - only used when websocket connection is used instead of webhooks
*/
_notifyTaskError(obj) {
if (this.requestor instanceof WsRequestor) {
this.requestor.request('jambonz:error', '/error', obj)
.catch((err) => this.logger.debug({err}, 'CallSession:_notifyTaskError - Error sending'));
}
}
_notifyTaskStatus(task, evt) {
if (this.notifyEvents && this.requestor instanceof WsRequestor) {
const obj = {...evt, id: task.id, name: task.name};
this.requestor.request('verb:status', '/status', obj)
.catch((err) => this.logger.debug({err}, 'CallSession:_notifyTaskStatus - Error sending'));
}
}
_awaitCommandsOrHangup() { _awaitCommandsOrHangup() {
assert(!this.wakeupResolver); assert(!this.wakeupResolver);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -1516,11 +1748,12 @@ class CallSession extends Emitter {
}); });
} }
_clearTasks(evt) { _clearTasks(backgroundGather, evt) {
if (this.requestor instanceof WsRequestor) { if (this.requestor instanceof WsRequestor && !backgroundGather.cleared) {
this.logger.info({evt}, 'CallSession:_clearTasks on event from background gather'); this.logger.info({evt}, 'CallSession:_clearTasks on event from background gather');
try { try {
this.kill(); backgroundGather.cleared = true;
this.kill(true);
} catch (err) {} } catch (err) {}
} }
} }

View File

@@ -34,6 +34,7 @@ class InboundCallSession extends CallSession {
_onCancel() { _onCancel() {
this.rootSpan.setAttributes({'call.termination': 'caller abandoned'}); this.rootSpan.setAttributes({'call.termination': 'caller abandoned'});
this.callInfo.callTerminationBy = 'caller';
this._notifyCallStatusChange({ this._notifyCallStatusChange({
callStatus: CallStatus.NoAnswer, callStatus: CallStatus.NoAnswer,
sipStatus: 487, sipStatus: 487,
@@ -69,6 +70,7 @@ class InboundCallSession extends CallSession {
assert(this.dlg.connectTime); assert(this.dlg.connectTime);
const duration = moment().diff(this.dlg.connectTime, 'seconds'); const duration = moment().diff(this.dlg.connectTime, 'seconds');
this.rootSpan.setAttributes({'call.termination': 'hangup by caller'}); this.rootSpan.setAttributes({'call.termination': 'hangup by caller'});
this.callInfo.callTerminationBy = 'caller';
this.emit('callStatusChange', { this.emit('callStatusChange', {
callStatus: CallStatus.Completed, callStatus: CallStatus.Completed,
duration 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. * This is invoked when the called party hangs up, in order to calculate the call duration.
*/ */
_callerHungup() { _callerHungup() {
this.callInfo.callTerminationBy = 'caller';
const duration = moment().diff(this.dlg.connectTime, 'seconds'); const duration = moment().diff(this.dlg.connectTime, 'seconds');
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration}); this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
this.logger.debug('RestCallSession: called party hung up'); this.logger.debug('RestCallSession: called party hung up');

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

@@ -2,7 +2,7 @@ const Task = require('./task');
const Emitter = require('events'); const Emitter = require('events');
const ConfirmCallSession = require('../session/confirm-call-session'); const ConfirmCallSession = require('../session/confirm-call-session');
const {TaskName, TaskPreconditions, BONG_TONE} = require('../utils/constants'); const {TaskName, TaskPreconditions, BONG_TONE} = require('../utils/constants');
const normalizeJambones = require('../utils/normalize-jambones'); const { normalizeJambones } = require('@jambonz/verb-specifications');
const makeTask = require('./make_task'); const makeTask = require('./make_task');
const bent = require('bent'); const bent = require('bent');
const assert = require('assert'); const assert = require('assert');
@@ -72,7 +72,7 @@ class Conference extends Task {
get shouldRecord() { return this.record.path; } get shouldRecord() { return this.record.path; }
get isRecording() { return this.recordingInProgress; } get isRecording() { return this.recordingInProgress; }
async exec(cs, ep) { async exec(cs, {ep}) {
await super.exec(cs); await super.exec(cs);
this.ep = ep; this.ep = ep;
const dlg = cs.dlg; const dlg = cs.dlg;
@@ -108,6 +108,10 @@ class Conference extends Task {
async kill(cs) { async kill(cs) {
super.kill(cs); super.kill(cs);
this.logger.info(`Conference:kill ${this.confName}`); this.logger.info(`Conference:kill ${this.confName}`);
if (this._playSession) {
this._playSession.kill();
this._playSession = null;
}
this.emitter.emit('kill'); this.emitter.emit('kill');
await this._doFinalMemberCheck(cs); await this._doFinalMemberCheck(cs);
if (this.ep && this.ep.connected) this.ep.conn.removeAllListeners('esl::event::CUSTOM::*') ; if (this.ep && this.ep.connected) this.ep.conn.removeAllListeners('esl::event::CUSTOM::*') ;
@@ -427,13 +431,19 @@ class Conference extends Task {
.catch((err) => this.logger.info({err}, 'Error deafing or undeafing participant')); .catch((err) => this.logger.info({err}, 'Error deafing or undeafing participant'));
} }
if (wait_hook) {
if (this.wait_hook)
delete this.wait_hook.url;
this.wait_hook = {url: wait_hook};
}
if (hookOnly && this._playSession) { if (hookOnly && this._playSession) {
this._playSession.kill(); this._playSession.kill();
this._playSession = null; this._playSession = null;
} }
if (wait_hook && this.conf_hold_status === 'hold') { if (this.wait_hook?.url && this.conf_hold_status === 'hold') {
const {dlg} = cs; const {dlg} = cs;
this._doWaitHookWhileOnHold(cs, dlg, wait_hook); this._doWaitHookWhileOnHold(cs, dlg, this.wait_hook);
} }
else if (this.conf_hold_status !== 'hold' && this._playSession) { else if (this.conf_hold_status !== 'hold' && this._playSession) {
this._playSession.kill(); this._playSession.kill();
@@ -444,7 +454,9 @@ class Conference extends Task {
async _doWaitHookWhileOnHold(cs, dlg, wait_hook) { async _doWaitHookWhileOnHold(cs, dlg, wait_hook) {
do { do {
try { try {
const tasks = await this._playHook(cs, dlg, wait_hook); let tasks = [];
if (wait_hook.url)
tasks = await this._playHook(cs, dlg, wait_hook.url);
if (0 === tasks.length) break; if (0 === tasks.length) break;
} catch (err) { } catch (err) {
if (!this.killed) { if (!this.killed) {
@@ -541,6 +553,9 @@ class Conference extends Task {
} }
this.logger.debug(`Conference:_playHook: executing ${tasks.length} tasks`); 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) { if (tasks.length > 0) {
this._playSession = new ConfirmCallSession({ this._playSession = new ConfirmCallSession({
logger: this.logger, logger: this.logger,
@@ -568,6 +583,10 @@ class Conference extends Task {
*/ */
_kicked(cs, dlg) { _kicked(cs, dlg) {
this.logger.info(`Conference:kicked - I was dropped from conference ${this.confName}, task is complete`); this.logger.info(`Conference:kicked - I was dropped from conference ${this.confName}, task is complete`);
if (this._playSession) {
this._playSession.kill();
this._playSession = null;
}
this.replaceEndpointAndEnd(cs); this.replaceEndpointAndEnd(cs);
} }

View File

@@ -4,15 +4,18 @@ const {TaskName, TaskPreconditions} = require('../utils/constants');
class TaskConfig extends Task { class TaskConfig extends Task {
constructor(logger, opts) { constructor(logger, opts) {
super(logger, opts); super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint;
[ [
'synthesizer', 'synthesizer',
'recognizer', 'recognizer',
'bargeIn', 'bargeIn',
'record' 'record',
'listen'
].forEach((k) => this[k] = this.data[k] || {}); ].forEach((k) => this[k] = this.data[k] || {});
if ('notifyEvents' in this.data) {
this.notifyEvents = !!this.data.notifyEvents;
}
if (this.bargeIn.enable) { if (this.bargeIn.enable) {
this.gatherOpts = { this.gatherOpts = {
verb: 'gather', verb: 'gather',
@@ -28,14 +31,17 @@ class TaskConfig extends Task {
}); });
} }
if (this.bargeIn.sticky) this.autoEnable = true; if (this.bargeIn.sticky) this.autoEnable = true;
this.preconditions = this.bargeIn.enable ? TaskPreconditions.Endpoint : TaskPreconditions.None; this.preconditions = (this.bargeIn.enable || this.record?.action || this.listen?.url || this.data.amd) ?
TaskPreconditions.Endpoint :
TaskPreconditions.None;
} }
get name() { return TaskName.Config; } get name() { return TaskName.Config; }
get hasSynthesizer() { return Object.keys(this.synthesizer).length; } get hasSynthesizer() { return Object.keys(this.synthesizer).length; }
get hasRecognizer() { return Object.keys(this.recognizer).length; } get hasRecognizer() { return Object.keys(this.recognizer).length; }
get hasRecording() { return Object.keys(this.record).length; }
get hasListen() { return Object.keys(this.listen).length; }
get summary() { get summary() {
const phrase = []; const phrase = [];
@@ -50,17 +56,34 @@ class TaskConfig extends Task {
const s = `{${v},${l}}`; const s = `{${v},${l}}`;
phrase.push(`set recognizer${s}`); phrase.push(`set recognizer${s}`);
} }
if (this.hasRecording) phrase.push(this.record.action);
if (this.hasListen) {
phrase.push(this.listen.enable ? `listen ${this.listen.url}` : 'stop listen');
}
if (this.data.amd) phrase.push('enable amd'); if (this.data.amd) phrase.push('enable amd');
return `${this.name}{${phrase.join(',')}`; if (this.notifyEvents) phrase.push(`event notification ${this.notifyEvents ? 'on' : 'off'}`);
return `${this.name}{${phrase.join(',')}`;
} }
async exec(cs) { async exec(cs, {ep} = {}) {
await super.exec(cs); await super.exec(cs);
if (this.notifyEvents) {
this.logger.debug(`turning event notification ${this.notifyEvents ? 'on' : 'off'}`);
cs.notifyEvents = !!this.data.notifyEvents;
}
if (this.data.amd) { if (this.data.amd) {
this.startAmd = cs.startAmd; this.startAmd = cs.startAmd;
this.stopAmd = cs.stopAmd; this.stopAmd = cs.stopAmd;
this.on('amd', this._onAmdEvent.bind(this, cs)); 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) { if (this.hasSynthesizer) {
@@ -87,13 +110,27 @@ class TaskConfig extends Task {
cs.asrTimeout = this.recognizer.asrTimeout; cs.asrTimeout = this.recognizer.asrTimeout;
cs.asrDtmfTerminationDigit = this.recognizer.asrDtmfTerminationDigit; 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({ this.logger.info({
recognizer: this.recognizer, recognizer: this.recognizer,
isContinuousAsr: cs.isContinuousAsr isContinuousAsr: cs.isContinuousAsr
}, 'Config: updated recognizer'); }, 'Config: updated recognizer');
} }
if ('enable' in this.bargeIn) { if ('enable' in this.bargeIn) {
if (this.gatherOpts) { if (this.bargeIn.enable === true && this.gatherOpts) {
this.gatherOpts.recognizer = this.hasRecognizer ? this.gatherOpts.recognizer = this.hasRecognizer ?
this.recognizer : this.recognizer :
{ {
@@ -103,22 +140,43 @@ class TaskConfig extends Task {
this.logger.info({opts: this.gatherOpts}, 'Config: enabling bargeIn'); this.logger.info({opts: this.gatherOpts}, 'Config: enabling bargeIn');
cs.enableBotMode(this.gatherOpts, this.autoEnable); cs.enableBotMode(this.gatherOpts, this.autoEnable);
} }
else { else if (this.bargeIn.enable === false) {
this.logger.info('Config: disabling bargeIn'); this.logger.info('Config: disabling bargeIn');
cs.disableBotMode(); cs.disableBotMode();
} }
} }
if (this.record.action) cs.notifyRecordOptions(this.record); if (this.record.action) {
try {
await cs.notifyRecordOptions(this.record);
} catch (err) {
this.logger.info({err}, 'Config: error starting recording');
}
}
if (this.hasListen) {
const {enable, ...opts} = this.listen;
if (enable) {
this.logger.debug({opts}, 'Config: enabling listen');
cs.startBackgroundListen({verb: 'listen', ...opts});
} else {
this.logger.info('Config: disabling listen');
cs.stopBackgroundListen();
}
}
} }
async kill(cs) { async kill(cs) {
super.kill(cs); super.kill(cs);
//if (this.ep && this.stopAmd) this.stopAmd(this.ep, this);
} }
_onAmdEvent(cs, evt) { _onAmdEvent(cs, evt) {
this.logger.info({evt}, 'Config:_onAmdEvent'); this.logger.info({evt}, 'Config:_onAmdEvent');
const {actionHook} = this.data.amd; const {actionHook} = this.data.amd;
this.performHook(cs, actionHook, evt); 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; } get name() { return TaskName.Dequeue; }
async exec(cs, ep) { async exec(cs, {ep}) {
await super.exec(cs); await super.exec(cs);
this.ep = ep; this.ep = ep;
this.queueName = `queue:${cs.accountSid}:${this.queueName}`; this.queueName = `queue:${cs.accountSid}:${this.queueName}`;

View File

@@ -248,7 +248,7 @@ class TaskDial extends Task {
const {span, ctx} = this.startChildSpan(`whisper:${task.summary}`); const {span, ctx} = this.startChildSpan(`whisper:${task.summary}`);
task.span = span; task.span = span;
task.ctx = ctx; 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(); span.end();
} }
this.logger.debug('Dial:whisper tasks complete'); this.logger.debug('Dial:whisper tasks complete');
@@ -376,9 +376,8 @@ class TaskDial extends Task {
} }
async _initializeInbound(cs) { async _initializeInbound(cs) {
const ep = await cs._evalEndpointPrecondition(this); const {ep} = await cs._evalEndpointPrecondition(this);
this.epOther = ep; this.epOther = ep;
debug(`Dial:__initializeInbound allocated ep for incoming call: ${ep.uuid}`);
/* send outbound legs back to the same SBC (to support static IP feature) */ /* send outbound legs back to the same SBC (to support static IP feature) */
if (!this.proxy) this.proxy = `${cs.req.source_address}:${cs.req.source_port}`; if (!this.proxy) this.proxy = `${cs.req.source_address}:${cs.req.source_port}`;
@@ -401,15 +400,19 @@ class TaskDial extends Task {
let fqdn; let fqdn;
if (!sbcAddress) throw new Error('no SBC found for outbound call'); if (!sbcAddress) throw new Error('no SBC found for outbound call');
this.headers = {
'X-Account-Sid': cs.accountSid,
...(req && req.has('X-CID') && {'X-CID': req.get('X-CID')}),
...(req && req.has('P-Asserted-Identity') && {'P-Asserted-Identity': req.get('P-Asserted-Identity')}),
// Put headers at the end to make sure opt.headers override all default behavior.
...this.headers
};
const opts = { const opts = {
headers: req && req.has('X-CID') ? Object.assign(this.headers, {'X-CID': req.get('X-CID')}) : this.headers, headers: this.headers,
proxy: `sip:${sbcAddress}`, proxy: `sip:${sbcAddress}`,
callingNumber: this.callerId || req.callingNumber callingNumber: this.callerId || req.callingNumber
}; };
opts.headers = {
...opts.headers,
'X-Account-Sid': cs.accountSid
};
const t = this.target.find((t) => t.type === 'teams'); const t = this.target.find((t) => t.type === 'teams');
if (t) { if (t) {
@@ -631,8 +634,8 @@ class TaskDial extends Task {
if (this.parentDtmfCollector) this._installDtmfDetection(cs, cs.dlg); if (this.parentDtmfCollector) this._installDtmfDetection(cs, cs.dlg);
if (this.childDtmfCollector) this._installDtmfDetection(cs, this.dlg); if (this.childDtmfCollector) this._installDtmfDetection(cs, this.dlg);
if (this.transcribeTask) this.transcribeTask.exec(cs, this.epOther, this.ep); if (this.transcribeTask) this.transcribeTask.exec(cs, {ep2: this.epOther, ep:this.ep});
if (this.listenTask) this.listenTask.exec(cs, this.epOther); if (this.listenTask) this.listenTask.exec(cs, {ep: this.epOther});
if (this.startAmd) { if (this.startAmd) {
try { try {
this.startAmd(cs, this.ep, this, this.data.amd); this.startAmd(cs, this.ep, this, this.data.amd);
@@ -642,7 +645,7 @@ class TaskDial extends Task {
} }
/* if we can release the media back to the SBC, do so now */ /* 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) { _bridgeEarlyMedia(sd) {
@@ -690,7 +693,10 @@ class TaskDial extends Task {
_onAmdEvent(cs, evt) { _onAmdEvent(cs, evt) {
this.logger.info({evt}, 'Dial:_onAmdEvent'); this.logger.info({evt}, 'Dial:_onAmdEvent');
const {actionHook} = this.data.amd; const {actionHook} = this.data.amd;
this.performHook(cs, actionHook, evt); this.performHook(cs, actionHook, evt)
.catch((err) => {
this.logger.error({err}, 'Dial:_onAmdEvent - error calling actionHook');
});
} }
} }

View File

@@ -3,7 +3,7 @@ const {TaskName, TaskPreconditions} = require('../../utils/constants');
const Intent = require('./intent'); const Intent = require('./intent');
const DigitBuffer = require('./digit-buffer'); const DigitBuffer = require('./digit-buffer');
const Transcription = require('./transcription'); const Transcription = require('./transcription');
const normalizeJambones = require('../../utils/normalize-jambones'); const { normalizeJambones } = require('@jambonz/verb-specifications');
class Dialogflow extends Task { class Dialogflow extends Task {
constructor(logger, opts) { constructor(logger, opts) {
@@ -64,7 +64,7 @@ class Dialogflow extends Task {
get name() { return TaskName.Dialogflow; } get name() { return TaskName.Dialogflow; }
async exec(cs, ep) { async exec(cs, {ep}) {
await super.exec(cs); await super.exec(cs);
try { try {

View File

@@ -12,7 +12,7 @@ class TaskDtmf extends Task {
get name() { return TaskName.Dtmf; } get name() { return TaskName.Dtmf; }
async exec(cs, ep) { async exec(cs, {ep}) {
await super.exec(cs); await super.exec(cs);
this.ep = ep; this.ep = ep;
try { try {

View File

@@ -1,7 +1,7 @@
const Task = require('./task'); const Task = require('./task');
const Emitter = require('events'); const Emitter = require('events');
const ConfirmCallSession = require('../session/confirm-call-session'); const ConfirmCallSession = require('../session/confirm-call-session');
const normalizeJambones = require('../utils/normalize-jambones'); const { normalizeJambones } = require('@jambonz/verb-specifications');
const makeTask = require('./make_task'); const makeTask = require('./make_task');
const {TaskName, TaskPreconditions, QueueResults, KillReason} = require('../utils/constants'); const {TaskName, TaskPreconditions, QueueResults, KillReason} = require('../utils/constants');
const bent = require('bent'); const bent = require('bent');
@@ -37,7 +37,7 @@ class TaskEnqueue extends Task {
get name() { return TaskName.Enqueue; } get name() { return TaskName.Enqueue; }
async exec(cs, ep) { async exec(cs, {ep}) {
await super.exec(cs); await super.exec(cs);
const dlg = cs.dlg; const dlg = cs.dlg;
this.queueName = `queue:${cs.accountSid}:${this.queueName}`; this.queueName = `queue:${cs.accountSid}:${this.queueName}`;

View File

@@ -3,21 +3,48 @@ const {
TaskName, TaskName,
TaskPreconditions, TaskPreconditions,
GoogleTranscriptionEvents, GoogleTranscriptionEvents,
NuanceTranscriptionEvents,
AwsTranscriptionEvents, AwsTranscriptionEvents,
AzureTranscriptionEvents AzureTranscriptionEvents,
DeepgramTranscriptionEvents,
SonioxTranscriptionEvents,
IbmTranscriptionEvents,
NvidiaTranscriptionEvents,
JambonzTranscriptionEvents
} = require('../utils/constants'); } = require('../utils/constants');
const makeTask = require('./make_task'); const makeTask = require('./make_task');
const assert = require('assert'); const assert = require('assert');
//const GATHER_STABILITY_THRESHOLD = Number(process.env.JAMBONZ_GATHER_STABILITY_THRESHOLD || 0.7);
const compileTranscripts = (logger, evt, arr) => {
if (!Array.isArray(arr) || arr.length === 0) return;
let t = '';
for (const a of arr) {
t += ` ${a.alternatives[0].transcript}`;
}
t += ` ${evt.alternatives[0].transcript}`;
evt.alternatives[0].transcript = t.trim();
};
class TaskGather extends Task { class TaskGather extends Task {
constructor(logger, opts, parentTask) { constructor(logger, opts, parentTask) {
super(logger, opts); super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint; this.preconditions = TaskPreconditions.Endpoint;
const {
setChannelVarsForStt,
normalizeTranscription,
removeSpeechListeners,
setSpeechCredentialsAtRuntime,
compileSonioxTranscripts
} = require('../utils/transcription-utils')(logger);
this.setChannelVarsForStt = setChannelVarsForStt;
this.normalizeTranscription = normalizeTranscription;
this.removeSpeechListeners = removeSpeechListeners;
this.compileSonioxTranscripts = compileSonioxTranscripts;
[ [
'finishOnKey', 'hints', 'input', 'numDigits', 'minDigits', 'maxDigits', 'finishOnKey', 'input', 'numDigits', 'minDigits', 'maxDigits',
'interDigitTimeout', 'partialResultHook', 'bargein', 'dtmfBargein', 'interDigitTimeout', 'partialResultHook', 'bargein', 'dtmfBargein',
'speechTimeout', 'timeout', 'say', 'play' 'speechTimeout', 'timeout', 'say', 'play'
].forEach((k) => this[k] = this.data[k]); ].forEach((k) => this[k] = this.data[k]);
@@ -27,53 +54,31 @@ class TaskGather extends Task {
/* timeout of zero means no timeout */ /* timeout of zero means no timeout */
this.timeout = this.timeout === 0 ? 0 : (this.timeout || 15) * 1000; this.timeout = this.timeout === 0 ? 0 : (this.timeout || 15) * 1000;
this.interim = this.partialResultHook || this.bargein; this.interim = !!this.partialResultHook || this.bargein || (this.timeout > 0);
this.listenDuringPrompt = this.data.listenDuringPrompt === false ? false : true; this.listenDuringPrompt = this.data.listenDuringPrompt === false ? false : true;
this.minBargeinWordCount = this.data.minBargeinWordCount || 0; this.minBargeinWordCount = this.data.minBargeinWordCount || 1;
if (this.data.recognizer) { if (this.data.recognizer) {
const recognizer = this.data.recognizer; const recognizer = this.data.recognizer;
this.vendor = recognizer.vendor; this.vendor = recognizer.vendor;
this.language = recognizer.language; this.language = recognizer.language;
this.hints = recognizer.hints || [];
this.hintsBoost = recognizer.hintsBoost; /* let credentials be supplied in the recognizer object at runtime */
this.profanityFilter = recognizer.profanityFilter; this.sttCredentials = setSpeechCredentialsAtRuntime(recognizer);
this.punctuation = !!recognizer.punctuation;
this.enhancedModel = !!recognizer.enhancedModel;
this.model = recognizer.model || 'command_and_search';
this.words = !!recognizer.words;
this.singleUtterance = recognizer.singleUtterance || true;
this.diarization = !!recognizer.diarization;
this.diarizationMinSpeakers = recognizer.diarizationMinSpeakers || 0;
this.diarizationMaxSpeakers = recognizer.diarizationMaxSpeakers || 0;
this.interactionType = recognizer.interactionType || 'unspecified';
this.naicsCode = recognizer.naicsCode || 0;
this.altLanguages = recognizer.altLanguages || [];
/* continuous ASR (i.e. compile transcripts until a special timeout or dtmf key) */ /* continuous ASR (i.e. compile transcripts until a special timeout or dtmf key) */
this.asrTimeout = typeof recognizer.asrTimeout === 'number' ? recognizer.asrTimeout * 1000 : 0; this.asrTimeout = typeof recognizer.asrTimeout === 'number' ? recognizer.asrTimeout * 1000 : 0;
if (this.asrTimeout > 0) this.asrDtmfTerminationDigit = recognizer.asrDtmfTerminationDigit; if (this.asrTimeout > 0) this.asrDtmfTerminationDigit = recognizer.asrDtmfTerminationDigit;
this.isContinuousAsr = this.asrTimeout > 0; this.isContinuousAsr = this.asrTimeout > 0;
/* vad: if provided, we dont connect to recognizer until voice activity is detected */ if (Array.isArray(this.data.recognizer.hints) &&
const {enable, voiceMs = 0, mode = -1} = recognizer.vad || {}; 0 == this.data.recognizer.hints.length && process.env.JAMBONES_GATHER_CLEAR_GLOBAL_HINTS_ON_EMPTY_HINTS) {
this.vad = {enable, voiceMs, mode}; logger.debug('Gather: an empty hints array was supplied, so we will mask global hints');
this.maskGlobalSttHints = true;
/* aws options */ }
this.vocabularyName = recognizer.vocabularyName; this.data.recognizer.hints = this.data.recognizer.hints || [];
this.vocabularyFilterName = recognizer.vocabularyFilterName; this.data.recognizer.altLanguages = this.data.recognizer.altLanguages || [];
this.filterMethod = recognizer.filterMethod;
/* microsoft options */
this.outputFormat = recognizer.outputFormat || 'simple';
this.profanityOption = recognizer.profanityOption || 'raw';
this.requestSnr = recognizer.requestSnr || false;
this.initialSpeechTimeoutMs = recognizer.initialSpeechTimeoutMs || 0;
this.azureServiceEndpoint = recognizer.azureServiceEndpoint;
}
else {
this.hints = [];
this.altLanguages = [];
} }
else this.data.recognizer = {hints: [], altLanguages: []};
this.digitBuffer = ''; this.digitBuffer = '';
this._earlyMedia = this.data.earlyMedia === true; this._earlyMedia = this.data.earlyMedia === true;
@@ -86,9 +91,12 @@ class TaskGather extends Task {
} }
if (!this.sayTask && !this.playTask) this.listenDuringPrompt = false; if (!this.sayTask && !this.playTask) this.listenDuringPrompt = false;
/* buffer speech for continueous asr */ /* buffer speech for continuous asr */
this._bufferedTranscripts = []; this._bufferedTranscripts = [];
/* buffer for soniox transcripts */
this._sonioxTranscripts = [];
this.parentTask = parentTask; this.parentTask = parentTask;
} }
@@ -116,11 +124,30 @@ class TaskGather extends Task {
return s; return s;
} }
async exec(cs, ep) { async exec(cs, {ep}) {
this.logger.debug('Gather:exec'); this.logger.debug('Gather:exec');
await super.exec(cs); await super.exec(cs);
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf); const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
const {getNuanceAccessToken, getIbmAccessToken} = cs.srf.locals.dbHelpers;
if (cs.hasGlobalSttHints && !this.maskGlobalSttHints) {
const {hints, hintsBoost} = cs.globalSttHints;
const setOfHints = new Set(this.data.recognizer.hints
.concat(hints)
.filter((h) => typeof h === 'string' && h.length > 0));
this.data.recognizer.hints = [...setOfHints];
if (!this.data.recognizer.hintsBoost && hintsBoost) this.data.recognizer.hintsBoost = hintsBoost;
this.logger.debug({hints: this.data.recognizer.hints, hintsBoost: this.data.recognizer.hintsBoost},
'Gather:exec - applying global sttHints');
}
if (cs.hasAltLanguages) {
this.data.recognizer.altLanguages = this.data.recognizer.altLanguages.concat(cs.altLanguages);
this.logger.debug({altLanguages: this.altLanguages},
'Gather:exec - applying altLanguages');
}
if (cs.hasGlobalSttPunctuation && !this.data.recognizer.punctuation) {
this.data.recognizer.punctuation = cs.globalSttPunctuation;
}
if (!this.isContinuousAsr && cs.isContinuousAsr) { if (!this.isContinuousAsr && cs.isContinuousAsr) {
this.isContinuousAsr = true; this.isContinuousAsr = true;
this.asrTimeout = cs.asrTimeout * 1000; this.asrTimeout = cs.asrTimeout * 1000;
@@ -130,10 +157,28 @@ class TaskGather extends Task {
asrDtmfTerminationDigit: this.asrDtmfTerminationDigit asrDtmfTerminationDigit: this.asrDtmfTerminationDigit
}, 'Gather:exec - enabling continuous ASR since it is turned on for the session'); }, 'Gather:exec - enabling continuous ASR since it is turned on for the session');
} }
const {JAMBONZ_GATHER_EARLY_HINTS_MATCH, JAMBONES_GATHER_EARLY_HINTS_MATCH} = process.env;
if ((JAMBONZ_GATHER_EARLY_HINTS_MATCH || JAMBONES_GATHER_EARLY_HINTS_MATCH) && this.needsStt &&
!this.isContinuousAsr &&
this.data.recognizer?.hints?.length > 0 && this.data.recognizer?.hints?.length <= 10) {
this.earlyHintsMatch = true;
this.interim = true;
this.logger.debug('Gather:exec - early hints match enabled');
}
this.ep = ep; this.ep = ep;
if ('default' === this.vendor || !this.vendor) this.vendor = cs.speechRecognizerVendor; if ('default' === this.vendor || !this.vendor) {
if ('default' === this.language || !this.language) this.language = cs.speechRecognizerLanguage; this.vendor = cs.speechRecognizerVendor;
this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt'); if (this.data.recognizer) this.data.recognizer.vendor = this.vendor;
}
if ('default' === this.language || !this.language) {
this.language = cs.speechRecognizerLanguage;
if (this.data.recognizer) this.data.recognizer.language = this.language;
}
if (!this.data.recognizer.vendor) {
this.data.recognizer.vendor = this.vendor;
}
if (this.needsStt && !this.sttCredentials) this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt');
if (this.needsStt && !this.sttCredentials) { if (this.needsStt && !this.sttCredentials) {
const {writeAlerts, AlertType} = cs.srf.locals; const {writeAlerts, AlertType} = cs.srf.locals;
this.logger.info(`TaskGather:exec - ERROR stt using ${this.vendor} requested but creds not supplied`); this.logger.info(`TaskGather:exec - ERROR stt using ${this.vendor} requested but creds not supplied`);
@@ -142,20 +187,45 @@ class TaskGather extends Task {
alert_type: AlertType.STT_NOT_PROVISIONED, alert_type: AlertType.STT_NOT_PROVISIONED,
vendor: this.vendor vendor: this.vendor
}).catch((err) => this.logger.info({err}, 'Error generating alert for no stt')); }).catch((err) => this.logger.info({err}, 'Error generating alert for no stt'));
// Notify application that STT vender is wrong.
throw new Error(`no speech-to-text service credentials for ${this.vendor} have been configured`); this.notifyError({
msg: 'ASR error',
details: `No speech-to-text service credentials for ${this.vendor} have been configured`
});
this.notifyTaskDone();
throw new Error(`No speech-to-text service credentials for ${this.vendor} have been configured`);
} }
if (this.vendor === 'nuance' && this.sttCredentials.client_id) {
/* get nuance access token */
const {client_id, secret} = this.sttCredentials;
const {access_token, servedFromCache} = await getNuanceAccessToken(client_id, secret, 'asr tts');
this.logger.debug({client_id}, `Gather:exec - got nuance access token ${servedFromCache ? 'from cache' : ''}`);
this.sttCredentials = {...this.sttCredentials, access_token};
}
else if (this.vendor == 'ibm' && this.sttCredentials.stt_api_key) {
/* get ibm access token */
const {stt_api_key, stt_region} = this.sttCredentials;
const {access_token, servedFromCache} = await getIbmAccessToken(stt_api_key);
this.logger.debug({stt_api_key}, `Gather:exec - got ibm access token ${servedFromCache ? 'from cache' : ''}`);
this.sttCredentials = {...this.sttCredentials, access_token, stt_region};
}
const startListening = (cs, ep) => { const startListening = (cs, ep) => {
this._startTimer(); this._startTimer();
if (this.isContinuousAsr && 0 === this.timeout) this._startAsrTimer(); if (this.isContinuousAsr && 0 === this.timeout) this._startAsrTimer();
if (this.input.includes('speech') && !this.listenDuringPrompt) { if (this.input.includes('speech') && !this.listenDuringPrompt) {
this._initSpeech(cs, ep) this._initSpeech(cs, ep)
.then(() => { .then(() => {
if (this.killed) {
this.logger.info('Gather:exec - task was quickly killed so do not transcribe');
return;
}
this._startTranscribing(ep); this._startTranscribing(ep);
return updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid); return updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid);
}) })
.catch(() => {}); .catch((err) => {
this.logger.error({err}, 'error in initSpeech');
});
} }
}; };
@@ -164,27 +234,49 @@ class TaskGather extends Task {
const {span, ctx} = this.startChildSpan(`nested:${this.sayTask.summary}`); const {span, ctx} = this.startChildSpan(`nested:${this.sayTask.summary}`);
this.sayTask.span = span; this.sayTask.span = span;
this.sayTask.ctx = ctx; 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) => { this.sayTask.on('playDone', (err) => {
span.end(); span.end();
if (err) this.logger.error({err}, 'Gather:exec Error playing tts'); if (err) this.logger.error({err}, 'Gather:exec Error playing tts');
this.logger.debug('Gather: nested say task completed'); this.logger.debug('Gather: nested say task completed');
if (!this.killed) startListening(cs, ep); if (!this.killed) {
startListening(cs, ep);
if (this.input.includes('speech') && this.vendor === 'nuance' && this.listenDuringPrompt) {
this.logger.debug('Gather:exec - starting transcription timers after say completes');
ep.startTranscriptionTimers((err) => {
if (err) this.logger.error({err}, 'Gather:exec - error starting transcription timers');
});
}
}
}); });
} }
else if (this.playTask) { else if (this.playTask) {
const {span, ctx} = this.startChildSpan(`nested:${this.playTask.summary}`); const {span, ctx} = this.startChildSpan(`nested:${this.playTask.summary}`);
this.playTask.span = span; this.playTask.span = span;
this.playTask.ctx = ctx; 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) => { this.playTask.on('playDone', (err) => {
span.end(); span.end();
if (err) this.logger.error({err}, 'Gather:exec Error playing url'); if (err) this.logger.error({err}, 'Gather:exec Error playing url');
this.logger.debug('Gather: nested play task completed'); this.logger.debug('Gather: nested play task completed');
if (!this.killed) startListening(cs, ep); if (!this.killed) {
startListening(cs, ep);
if (this.input.includes('speech') && this.vendor === 'nuance' && this.listenDuringPrompt) {
this.logger.debug('Gather:exec - starting transcription timers after play completes');
ep.startTranscriptionTimers((err) => {
if (err) this.logger.error({err}, 'Gather:exec - error starting transcription timers');
});
}
}
}); });
} }
else startListening(cs, ep); else {
if (this.killed) {
this.logger.info('Gather:exec - task was immediately killed so do not transcribe');
return;
}
startListening(cs, ep);
}
if (this.input.includes('speech') && this.listenDuringPrompt) { if (this.input.includes('speech') && this.listenDuringPrompt) {
await this._initSpeech(cs, ep); await this._initSpeech(cs, ep);
@@ -201,14 +293,7 @@ class TaskGather extends Task {
} catch (err) { } catch (err) {
this.logger.error(err, 'TaskGather:exec error'); this.logger.error(err, 'TaskGather:exec error');
} }
ep.removeCustomEventListener(GoogleTranscriptionEvents.Transcription); this.removeSpeechListeners(ep);
ep.removeCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance);
ep.removeCustomEventListener(GoogleTranscriptionEvents.VadDetected);
ep.removeCustomEventListener(AwsTranscriptionEvents.Transcription);
ep.removeCustomEventListener(AwsTranscriptionEvents.VadDetected);
ep.removeCustomEventListener(AzureTranscriptionEvents.Transcription);
ep.removeCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected);
ep.removeCustomEventListener(AzureTranscriptionEvents.VadDetected);
} }
kill(cs) { kill(cs) {
@@ -216,15 +301,21 @@ class TaskGather extends Task {
this._killAudio(cs); this._killAudio(cs);
this.ep.removeAllListeners('dtmf'); this.ep.removeAllListeners('dtmf');
clearTimeout(this.interDigitTimer); clearTimeout(this.interDigitTimer);
this._clearAsrTimer();
this.playTask?.span.end(); this.playTask?.span.end();
this.sayTask?.span.end(); this.sayTask?.span.end();
this._resolve('killed'); this._resolve('killed');
} }
updateTimeout(timeout) { updateTaskInProgress(opts) {
this.logger.info(`TaskGather:updateTimeout - updating timeout to ${timeout}`); 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.timeout = timeout;
this._startTimer(); this._startTimer();
return true;
} }
_onDtmf(cs, ep, evt) { _onDtmf(cs, ep, evt) {
@@ -263,93 +354,101 @@ class TaskGather extends Task {
} }
async _initSpeech(cs, ep) { async _initSpeech(cs, ep) {
const opts = {}; const opts = this.setChannelVarsForStt(this, this.sttCredentials, this.data.recognizer);
switch (this.vendor) {
case 'google':
this.bugname = 'google_transcribe';
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance, this._onEndOfUtterance.bind(this, cs, ep));
ep.addCustomEventListener(GoogleTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep));
break;
if (this.vad?.enable) { case 'aws':
opts.START_RECOGNIZING_ON_VAD = 1; case 'polly':
if (this.vad.voiceMs) opts.RECOGNIZER_VAD_VOICE_MS = this.vad.voiceMs; this.bugname = 'aws_transcribe';
else opts.RECOGNIZER_VAD_VOICE_MS = 125; ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
if (this.vad.mode >= 0 && this.vad.mode <= 3) opts.RECOGNIZER_VAD_MODE = this.vad.mode; ep.addCustomEventListener(AwsTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep));
} break;
case 'microsoft':
this.bugname = 'azure_transcribe';
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected,
this._onNoSpeechDetected.bind(this, cs, ep));
ep.addCustomEventListener(AzureTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep));
break;
case 'nuance':
this.bugname = 'nuance_transcribe';
ep.addCustomEventListener(NuanceTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(NuanceTranscriptionEvents.StartOfSpeech,
this._onStartOfSpeech.bind(this, cs, ep));
ep.addCustomEventListener(NuanceTranscriptionEvents.TranscriptionComplete,
this._onTranscriptionComplete.bind(this, cs, ep));
ep.addCustomEventListener(NuanceTranscriptionEvents.VadDetected,
this._onVadDetected.bind(this, cs, ep));
if ('google' === this.vendor) { /* stall timers until prompt finishes playing */
this.bugname = 'google_trancribe'; if ((this.sayTask || this.playTask) && this.listenDuringPrompt) {
if (this.sttCredentials) opts.GOOGLE_APPLICATION_CREDENTIALS = JSON.stringify(this.sttCredentials.credentials); opts.NUANCE_STALL_TIMERS = 1;
[
['enhancedModel', 'GOOGLE_SPEECH_USE_ENHANCED'],
['separateRecognitionPerChannel', 'GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL'],
['profanityFilter', 'GOOGLE_SPEECH_PROFANITY_FILTER'],
['punctuation', 'GOOGLE_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION'],
['words', 'GOOGLE_SPEECH_ENABLE_WORD_TIME_OFFSETS'],
['singleUtterance', 'GOOGLE_SPEECH_SINGLE_UTTERANCE'],
['diarization', 'GOOGLE_SPEECH_PROFANITY_FILTER']
].forEach((arr) => {
if (this[arr[0]]) opts[arr[1]] = true;
});
if (this.hints.length > 1) {
opts.GOOGLE_SPEECH_HINTS = this.hints.join(',');
if (typeof this.hintsBoost === 'number') {
opts.GOOGLE_SPEECH_HINTS_BOOST = this.hintsBoost;
} }
} break;
if (this.altLanguages.length > 1) opts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
if ('unspecified' !== this.interactionType) {
opts.GOOGLE_SPEECH_METADATA_INTERACTION_TYPE = this.interactionType;
}
opts.GOOGLE_SPEECH_MODEL = this.model;
if (this.diarization && this.diarizationMinSpeakers > 0) {
opts.GOOGLE_SPEECH_SPEAKER_DIARIZATION_MIN_SPEAKER_COUNT = this.diarizationMinSpeakers;
}
if (this.diarization && this.diarizationMaxSpeakers > 0) {
opts.GOOGLE_SPEECH_SPEAKER_DIARIZATION_MAX_SPEAKER_COUNT = this.diarizationMaxSpeakers;
}
if (this.naicsCode > 0) opts.GOOGLE_SPEECH_METADATA_INDUSTRY_NAICS_CODE = this.naicsCode;
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance, this._onEndOfUtterance.bind(this, cs, ep));
ep.addCustomEventListener(GoogleTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep));
}
else if (['aws', 'polly'].includes(this.vendor)) {
this.bugname = 'aws_trancribe';
if (this.vocabularyName) opts.AWS_VOCABULARY_NAME = this.vocabularyName;
if (this.vocabularyFilterName) {
opts.AWS_VOCABULARY_NAME = this.vocabularyFilterName;
opts.AWS_VOCABULARY_FILTER_METHOD = this.filterMethod || 'mask';
}
if (this.sttCredentials) {
Object.assign(opts, {
AWS_ACCESS_KEY_ID: this.sttCredentials.accessKeyId,
AWS_SECRET_ACCESS_KEY: this.sttCredentials.secretAccessKey,
AWS_REGION: this.sttCredentials.region
});
}
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(AwsTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep));
}
else if ('microsoft' === this.vendor) {
this.bugname = 'azure_trancribe';
if (this.sttCredentials) {
Object.assign(opts, {
'AZURE_SUBSCRIPTION_KEY': this.sttCredentials.api_key,
'AZURE_REGION': this.sttCredentials.region
});
}
if (this.hints && this.hints.length > 1) {
opts.AZURE_SPEECH_HINTS = this.hints.map((h) => h.trim()).join(',');
}
if (this.altLanguages && this.altLanguages.length > 0) {
opts.AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
}
if (this.requestSnr) opts.AZURE_REQUEST_SNR = 1;
if (this.profanityOption && this.profanityOption !== 'raw') opts.AZURE_PROFANITY_OPTION = this.profanityOption;
if (this.azureServiceEndpoint) opts.AZURE_SERVICE_ENDPOINT = this.azureServiceEndpoint;
if (this.initialSpeechTimeoutMs > 0) opts.AZURE_INITIAL_SPEECH_TIMEOUT_MS = this.initialSpeechTimeoutMs;
else if (this.timeout === 0) opts.AZURE_INITIAL_SPEECH_TIMEOUT_MS = 120000; // lengthy
opts.AZURE_USE_OUTPUT_FORMAT_DETAILED = 1;
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep)); case 'deepgram':
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected, this._onNoSpeechDetected.bind(this, cs, ep)); this.bugname = 'deepgram_transcribe';
ep.addCustomEventListener(AzureTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep)); ep.addCustomEventListener(DeepgramTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(DeepgramTranscriptionEvents.Connect, this._onDeepgramConnect.bind(this, cs, ep));
ep.addCustomEventListener(DeepgramTranscriptionEvents.ConnectFailure,
this._onDeepGramConnectFailure.bind(this, cs, ep));
break;
case 'soniox':
this.bugname = 'soniox_transcribe';
ep.addCustomEventListener(SonioxTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
break;
case 'ibm':
this.bugname = 'ibm_transcribe';
ep.addCustomEventListener(IbmTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(IbmTranscriptionEvents.Connect, this._onIbmConnect.bind(this, cs, ep));
ep.addCustomEventListener(IbmTranscriptionEvents.ConnectFailure,
this._onIbmConnectFailure.bind(this, cs, ep));
break;
case 'nvidia':
this.bugname = 'nvidia_transcribe';
ep.addCustomEventListener(NvidiaTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(NvidiaTranscriptionEvents.StartOfSpeech,
this._onStartOfSpeech.bind(this, cs, ep));
ep.addCustomEventListener(NvidiaTranscriptionEvents.TranscriptionComplete,
this._onTranscriptionComplete.bind(this, cs, ep));
ep.addCustomEventListener(NvidiaTranscriptionEvents.VadDetected,
this._onVadDetected.bind(this, cs, ep));
/* I think nvidia has this (??) - stall timers until prompt finishes playing */
if ((this.sayTask || this.playTask) && this.listenDuringPrompt) {
opts.NVIDIA_STALL_TIMERS = 1;
}
break;
default:
if (this.vendor.startsWith('custom:')) {
this.bugname = `${this.vendor}_transcribe`;
ep.addCustomEventListener(JambonzTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(JambonzTranscriptionEvents.Connect, this._onJambonzConnect.bind(this, cs, ep));
ep.addCustomEventListener(JambonzTranscriptionEvents.ConnectFailure,
this._onJambonzConnectFailure.bind(this, cs, ep));
break;
}
else {
this.notifyError({ msg: 'ASR error', details:`Invalid vendor ${this.vendor}`});
this.notifyTaskDone();
throw new Error(`Invalid vendor ${this.vendor}`);
}
} }
/* common handler for all stt engine errors */
ep.addCustomEventListener(JambonzTranscriptionEvents.Error, this._onJambonzError.bind(this, cs, ep));
await ep.set(opts) await ep.set(opts)
.catch((err) => this.logger.info(err, 'Error setting channel variables')); .catch((err) => this.logger.info(err, 'Error setting channel variables'));
} }
@@ -448,45 +547,53 @@ class TaskGather extends Task {
_onTranscription(cs, ep, evt, fsEvent) { _onTranscription(cs, ep, evt, fsEvent) {
// make sure this is not a transcript from answering machine detection // make sure this is not a transcript from answering machine detection
const bugname = fsEvent.getHeader('media-bugname'); const bugname = fsEvent.getHeader('media-bugname');
const finished = fsEvent.getHeader('transcription-session-finished');
this.logger.debug({evt, bugname, finished}, `Gather:_onTranscription for vendor ${this.vendor}`);
if (bugname && this.bugname !== bugname) return; if (bugname && this.bugname !== bugname) return;
if ('aws' === this.vendor && Array.isArray(evt) && evt.length > 0) evt = evt[0]; if (this.vendor === 'ibm') {
if ('microsoft' === this.vendor) { if (evt?.state === 'listening') return;
const final = evt.RecognitionStatus === 'Success'; }
if (final) {
// don't sort based on confidence: https://github.com/Azure-Samples/cognitive-services-speech-sdk/issues/1463 evt = this.normalizeTranscription(evt, this.vendor, 1, this.language);
//const nbest = evt.NBest.sort((a, b) => b.Confidence - a.Confidence);
const nbest = evt.NBest; if (this.earlyHintsMatch && evt.is_final === false) {
const language_code = evt.PrimaryLanguage?.Language || this.language; const transcript = evt.alternatives[0].transcript?.toLowerCase();
evt = { const hints = this.data.recognizer?.hints || [];
is_final: true, if (hints.find((h) => h.toLowerCase() === transcript)) {
language_code, this.logger.debug({evt}, 'Gather:_onTranscription: early hint match');
alternatives: [ this._resolve('speech', evt);
{ return;
confidence: nbest[0].Confidence,
transcript: nbest[0].Display
}
]
};
}
else {
evt = {
is_final: false,
alternatives: [
{
transcript: evt.Text
}
]
};
} }
} }
/* count words for bargein feature */
const words = evt.alternatives[0]?.transcript.split(' ').length;
const bufferedWords = this._sonioxTranscripts.length +
this._bufferedTranscripts.reduce((count, e) => count + e.alternatives[0]?.transcript.split(' ').length, 0);
if (evt.is_final) { if (evt.is_final) {
if (evt.alternatives[0].transcript === '' && !this.callSession.callGone && !this.killed) { if (evt.alternatives[0].transcript === '' && !this.callSession.callGone && !this.killed) {
this.logger.info({evt}, 'TaskGather:_onTranscription - got empty transcript, listen again'); if (finished === 'true' && ['microsoft', 'deepgram'].includes(this.vendor)) {
return this._startTranscribing(ep); this.logger.debug({evt}, 'TaskGather:_onTranscription - got empty transcript from old gather, disregarding');
}
else {
this.logger.info({evt}, 'TaskGather:_onTranscription - got empty transcript, continue listening');
}
return;
} }
if (this.isContinuousAsr) { if (this.isContinuousAsr) {
/* append the transcript and start listening again for asrTimeout */ /* 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.logger.info({evt}, 'TaskGather:_onTranscription - got transcript during continous asr');
this._bufferedTranscripts.push(evt); this._bufferedTranscripts.push(evt);
this._clearTimer(); this._clearTimer();
@@ -495,9 +602,28 @@ class TaskGather extends Task {
return this._resolve(this._bufferedTranscripts.length > 0 ? 'speech' : 'timeout'); return this._resolve(this._bufferedTranscripts.length > 0 ? 'speech' : 'timeout');
} }
this._startAsrTimer(); this._startAsrTimer();
return this._startTranscribing(ep);
/* some STT engines will keep listening after a final response, so no need to restart */
if (!['soniox', 'aws', 'microsoft', 'deepgram'].includes(this.vendor)) this._startTranscribing(ep);
}
else {
if (this.bargein && (words + bufferedWords) < this.minBargeinWordCount) {
this.logger.debug({evt, words, bufferedWords},
'TaskGather:_onTranscription - final transcript but < min barge words');
this._bufferedTranscripts.push(evt);
this._startTranscribing(ep);
return;
}
else {
if (this.vendor === 'soniox') {
/* compile transcripts into one */
this._sonioxTranscripts.push(evt.vendor.finalWords);
evt = this.compileSonioxTranscripts(this._sonioxTranscripts, 1, this.language);
this._sonioxTranscripts = [];
}
this._resolve('speech', evt);
}
} }
else this._resolve('speech', evt);
} }
else { else {
/* google has a measure of stability: /* google has a measure of stability:
@@ -505,9 +631,9 @@ class TaskGather extends Task {
others do not. others do not.
*/ */
//const isStableEnough = typeof evt.stability === 'undefined' || evt.stability > GATHER_STABILITY_THRESHOLD; //const isStableEnough = typeof evt.stability === 'undefined' || evt.stability > GATHER_STABILITY_THRESHOLD;
this._clearTimer();
if (this.bargein && /* isStableEnough && */ this._startTimer();
evt.alternatives[0].transcript.split(' ').length >= this.minBargeinWordCount) { if (this.bargein && (words + bufferedWords) >= this.minBargeinWordCount) {
if (!this.playComplete) { if (!this.playComplete) {
this.logger.debug({transcript: evt.alternatives[0].transcript}, 'killing audio due to speech'); this.logger.debug({transcript: evt.alternatives[0].transcript}, 'killing audio due to speech');
this.emit('vad'); this.emit('vad');
@@ -517,9 +643,16 @@ class TaskGather extends Task {
if (this.partialResultHook) { if (this.partialResultHook) {
const b3 = this.getTracingPropagation(); const b3 = this.getTracingPropagation();
const httpHeaders = b3 && {b3}; 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)); this.cs.callInfo, httpHeaders));
} }
if (this.vendor === 'soniox') {
this._clearTimer();
if (evt.vendor.finalWords.length) {
this.logger.debug({evt}, 'TaskGather:_onTranscription - buffering soniox transcript');
this._sonioxTranscripts.push(evt.vendor.finalWords);
}
}
} }
} }
_onEndOfUtterance(cs, ep) { _onEndOfUtterance(cs, ep) {
@@ -528,11 +661,98 @@ class TaskGather extends Task {
this._killAudio(cs); this._killAudio(cs);
} }
if (!this.resolved && !this.killed) { // DCH: commenting out because my experience is that the google STT engine
// will keep listening after it detects end of utterance, and will return a final transcript
// My earlier understanding that we needed to stop and restart the recognizer appears incorrect.
/*
if (!this.resolved && !this.killed && !this._bufferedTranscripts.length) {
this._startTranscribing(ep); this._startTranscribing(ep);
} }
*/
} }
_onStartOfSpeech(cs, ep) {
this.logger.debug('TaskGather:_onStartOfSpeech');
if (this.bargein) {
this._killAudio(cs);
}
}
_onTranscriptionComplete(cs, ep) {
this.logger.debug('TaskGather:_onTranscriptionComplete');
}
_onDeepgramConnect(_cs, _ep) {
this.logger.debug('TaskGather:_onDeepgramConnect');
}
_onJambonzConnect(_cs, _ep) {
this.logger.debug('TaskGather:_onJambonzConnect');
}
_onJambonzError(cs, _ep, evt) {
this.logger.info({evt}, 'TaskGather:_onJambonzError');
const {writeAlerts, AlertType} = cs.srf.locals;
if (this.vendor === 'nuance') {
const {code, error} = evt;
if (code === 404 && error === 'No speech') return this._resolve('timeout');
if (code === 413 && error === 'Too much speech') return this._resolve('timeout');
}
this.logger.info({evt}, 'TaskGather:_onJambonzError');
writeAlerts({
account_sid: cs.accountSid,
alert_type: AlertType.STT_FAILURE,
message: `Custom speech vendor ${this.vendor} error: ${evt.error}`,
vendor: this.vendor,
}).catch((err) => this.logger.info({err}, 'Error generating alert for jambonz custom connection failure'));
this.notifyError({msg: 'ASR error', details:`Custom speech vendor ${this.vendor} error: ${evt.error}`});
}
_onDeepGramConnectFailure(cs, _ep, evt) {
const {reason} = evt;
const {writeAlerts, AlertType} = cs.srf.locals;
this.logger.info({evt}, 'TaskGather:_onDeepgramConnectFailure');
writeAlerts({
account_sid: cs.accountSid,
alert_type: AlertType.STT_FAILURE,
message: `Failed connecting to Deepgram speech recognizer: ${reason}`,
vendor: 'deepgram',
}).catch((err) => this.logger.info({err}, 'Error generating alert for deepgram connection failure'));
this.notifyError({msg: 'ASR error', details:`Failed connecting to speech vendor deepgram: ${reason}`});
this.notifyTaskDone();
}
_onJambonzConnectFailure(cs, _ep, evt) {
const {reason} = evt;
const {writeAlerts, AlertType} = cs.srf.locals;
this.logger.info({evt}, 'TaskGather:_onJambonzConnectFailure');
writeAlerts({
account_sid: cs.accountSid,
alert_type: AlertType.STT_FAILURE,
message: `Failed connecting to ${this.vendor} speech recognizer: ${reason}`,
vendor: this.vendor,
}).catch((err) => this.logger.info({err}, 'Error generating alert for jambonz custom connection failure'));
this.notifyError({msg: 'ASR error', details:`Failed connecting to speech vendor ${this.vendor}: ${reason}`});
this.notifyTaskDone();
}
_onIbmConnect(_cs, _ep) {
this.logger.debug('TaskGather:_onIbmConnect');
}
_onIbmConnectFailure(cs, _ep, evt) {
const {reason} = evt;
const {writeAlerts, AlertType} = cs.srf.locals;
this.logger.info({evt}, 'TaskGather:_onIbmConnectFailure');
writeAlerts({
account_sid: cs.accountSid,
alert_type: AlertType.STT_FAILURE,
message: `Failed connecting to IBM watson speech recognizer: ${reason}`,
vendor: 'ibm',
}).catch((err) => this.logger.info({err}, 'Error generating alert for IBM connection failure'));
this.notifyError({msg: 'ASR error', details:`Failed connecting to speech vendor IBM: ${reason}`});
this.notifyTaskDone();
}
_onIbmError(cs, _ep, evt) {
this.logger.info({evt}, 'TaskGather:_onIbmError'); }
_onVadDetected(cs, ep) { _onVadDetected(cs, ep) {
if (this.bargein && this.minBargeinWordCount === 0) { if (this.bargein && this.minBargeinWordCount === 0) {
this.logger.debug('TaskGather:_onVadDetected'); this.logger.debug('TaskGather:_onVadDetected');
@@ -541,10 +761,17 @@ class TaskGather extends Task {
} }
} }
_onNoSpeechDetected(cs, ep) { _onNoSpeechDetected(cs, ep, evt, fsEvent) {
if (!this.callSession.callGone && !this.killed) { if (!this.callSession.callGone && !this.killed) {
this.logger.debug('TaskGather:_onNoSpeechDetected - listen again'); const finished = fsEvent.getHeader('transcription-session-finished');
return this._startTranscribing(ep); 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;
} }
} }
@@ -563,9 +790,13 @@ class TaskGather extends Task {
}; };
this.logger.debug({evt}, 'TaskGather:resolve continuous asr'); 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)}); 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}) this.ep.stopTranscription({vendor: this.vendor})
.catch((err) => this.logger.error({err}, 'Error stopping transcription')); .catch((err) => this.logger.error({err}, 'Error stopping transcription'));
} }

View File

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

View File

@@ -8,7 +8,7 @@ class TaskLeave extends Task {
get name() { return TaskName.Leave; } get name() { return TaskName.Leave; }
async exec(cs, ep) { async exec(cs, {ep}) {
await super.exec(cs); await super.exec(cs);
await this.awaitTaskDone(); await this.awaitTaskDone();
} }

View File

@@ -1,6 +1,6 @@
const Task = require('./task'); const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants'); const {TaskName, TaskPreconditions} = require('../utils/constants');
const normalizeJambones = require('../utils/normalize-jambones'); const { normalizeJambones } = require('@jambonz/verb-specifications');
class Lex extends Task { class Lex extends Task {
constructor(logger, opts) { constructor(logger, opts) {
@@ -44,7 +44,7 @@ class Lex extends Task {
get name() { return TaskName.Lex; } get name() { return TaskName.Lex; }
async exec(cs, ep) { async exec(cs, {ep}) {
await super.exec(cs); await super.exec(cs);
try { try {

View File

@@ -10,7 +10,7 @@ class TaskListen extends Task {
[ [
'action', 'auth', 'method', 'url', 'finishOnKey', 'maxLength', 'metadata', 'mixType', 'passDtmf', 'playBeep', 'action', 'auth', 'method', 'url', 'finishOnKey', 'maxLength', 'metadata', 'mixType', 'passDtmf', 'playBeep',
'sampleRate', 'timeout', 'transcribe', 'wsAuth' 'sampleRate', 'timeout', 'transcribe', 'wsAuth', 'disableBidirectionalAudio'
].forEach((k) => this[k] = this.data[k]); ].forEach((k) => this[k] = this.data[k]);
this.mixType = this.mixType || 'mono'; this.mixType = this.mixType || 'mono';
@@ -26,7 +26,7 @@ class TaskListen extends Task {
get name() { return TaskName.Listen; } get name() { return TaskName.Listen; }
async exec(cs, ep) { async exec(cs, {ep}) {
await super.exec(cs); await super.exec(cs);
this.ep = ep; this.ep = ep;
this._dtmfHandler = this._onDtmf.bind(this, ep); this._dtmfHandler = this._onDtmf.bind(this, ep);
@@ -40,7 +40,7 @@ class TaskListen extends Task {
const {span, ctx} = this.startChildSpan(`nested:${this.transcribeTask.summary}`); const {span, ctx} = this.startChildSpan(`nested:${this.transcribeTask.summary}`);
this.transcribeTask.span = span; this.transcribeTask.span = span;
this.transcribeTask.ctx = ctx; this.transcribeTask.ctx = ctx;
this.transcribeTask.exec(cs, ep) this.transcribeTask.exec(cs, {ep})
.then((result) => span.end()) .then((result) => span.end())
.catch((err) => span.end()); .catch((err) => span.end());
} }
@@ -136,7 +136,9 @@ class TaskListen extends Task {
} }
/* support bi-directional audio */ /* support bi-directional audio */
ep.addCustomEventListener(ListenEvents.PlayAudio, this._onPlayAudio.bind(this, ep)); if (!this.disableBiDirectionalAudio) {
ep.addCustomEventListener(ListenEvents.PlayAudio, this._onPlayAudio.bind(this, ep));
}
ep.addCustomEventListener(ListenEvents.KillAudio, this._onKillAudio.bind(this, ep)); ep.addCustomEventListener(ListenEvents.KillAudio, this._onKillAudio.bind(this, ep));
ep.addCustomEventListener(ListenEvents.Disconnect, this._onDisconnect.bind(this, ep)); ep.addCustomEventListener(ListenEvents.Disconnect, this._onDisconnect.bind(this, ep));
} }
@@ -219,7 +221,7 @@ class TaskListen extends Task {
this.logger.debug('Listen:whisper tasks starting'); this.logger.debug('Listen:whisper tasks starting');
while (tasks.length && !cs.callGone) { while (tasks.length && !cs.callGone) {
const task = tasks.shift(); const task = tasks.shift();
await task.exec(cs, this.ep); await task.exec(cs, {ep: this.ep});
} }
this.logger.debug('Listen:whisper tasks complete'); this.logger.debug('Listen:whisper tasks complete');
} catch (err) { } catch (err) {

View File

@@ -1,4 +1,4 @@
const Task = require('./task'); const { validateVerb } = require('@jambonz/verb-specifications');
const {TaskName} = require('../utils/constants'); const {TaskName} = require('../utils/constants');
const errBadInstruction = new Error('malformed jambonz application payload'); const errBadInstruction = new Error('malformed jambonz application payload');
@@ -12,7 +12,7 @@ function makeTask(logger, obj, parent) {
if (typeof data !== 'object') { if (typeof data !== 'object') {
throw errBadInstruction; throw errBadInstruction;
} }
Task.validate(name, data); validateVerb(name, data, logger);
switch (name) { switch (name) {
case TaskName.SipDecline: case TaskName.SipDecline:
const TaskSipDecline = require('./sip_decline'); const TaskSipDecline = require('./sip_decline');

View File

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

View File

@@ -10,7 +10,7 @@ class TaskPause extends Task {
get name() { return TaskName.Pause; } get name() { return TaskName.Pause; }
async exec(cs, ep) { async exec(cs) {
await super.exec(cs); await super.exec(cs);
this.timer = setTimeout(this.notifyTaskDone.bind(this), this.length * 1000); this.timer = setTimeout(this.notifyTaskDone.bind(this), this.length * 1000);
await this.awaitTaskDone(); await this.awaitTaskDone();

View File

@@ -7,6 +7,8 @@ class TaskPlay extends Task {
this.preconditions = TaskPreconditions.Endpoint; this.preconditions = TaskPreconditions.Endpoint;
this.url = this.data.url; this.url = this.data.url;
this.seekOffset = this.data.seekOffset || -1;
this.timeoutSecs = this.data.timeoutSecs || -1;
this.loop = this.data.loop || 1; this.loop = this.data.loop || 1;
this.earlyMedia = this.data.earlyMedia === true; this.earlyMedia = this.data.earlyMedia === true;
} }
@@ -17,18 +19,54 @@ class TaskPlay extends Task {
return `${this.name}:{url=${this.url}}`; return `${this.name}:{url=${this.url}}`;
} }
async exec(cs, ep) { async exec(cs, {ep}) {
await super.exec(cs); await super.exec(cs);
this.ep = ep; this.ep = ep;
let timeout;
let playbackSeconds = 0;
let playbackMilliseconds = 0;
let completed = !(this.timeoutSecs > 0 || this.loop);
if (this.timeoutSecs > 0) {
timeout = setTimeout(async() => {
completed = true;
try {
await this.kill(cs);
} catch (err) {
this.logger.info(err, 'Error killing audio on timeoutSecs');
}
}, this.timeoutSecs * 1000);
}
try { try {
this.notifyStatus({event: 'start-playback'});
while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep.connected) { while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep.connected) {
if (cs.isInConference) { if (cs.isInConference) {
const {memberId, confName, confUuid} = cs; const {memberId, confName, confUuid} = cs;
await this.playToConfMember(this.ep, memberId, confName, confUuid, this.url); if (Array.isArray(this.url)) {
for (const playUrl of this.url) {
await this.playToConfMember(this.ep, memberId, confName, confUuid, playUrl);
}
} else {
await this.playToConfMember(this.ep, memberId, confName, confUuid, this.url);
}
} else {
let file = this.url;
if (this.seekOffset >= 0) {
file = {file: this.url, seekOffset: this.seekOffset};
this.seekOffset = -1;
}
const result = await ep.play(file);
playbackSeconds += parseInt(result.playbackSeconds);
playbackMilliseconds += parseInt(result.playbackMilliseconds);
if (this.killed || !this.loop || completed) {
if (timeout) clearTimeout(timeout);
await this.performAction(
Object.assign(result, {reason: 'playCompleted', playbackSeconds, playbackMilliseconds}),
!(this.parentTask || cs.isConfirmCallSession));
}
} }
else await ep.play(this.url);
} }
} catch (err) { } catch (err) {
if (timeout) clearTimeout(timeout);
this.logger.info(err, `TaskPlay:exec - error playing ${this.url}`); this.logger.info(err, `TaskPlay:exec - error playing ${this.url}`);
} }
this.emit('playDone'); this.emit('playDone');
@@ -43,7 +81,8 @@ class TaskPlay extends Task {
this.killPlayToConfMember(this.ep, memberId, confName); this.killPlayToConfMember(this.ep, memberId, confName);
} }
else { else {
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio')); this.notifyStatus({event: 'kill-playback'});
this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
} }
} }
} }

View File

@@ -20,7 +20,7 @@ class Rasa extends Task {
return this.reportedFinalAction || this.isReplacingApplication; return this.reportedFinalAction || this.isReplacingApplication;
} }
async exec(cs, ep) { async exec(cs, {ep}) {
await super.exec(cs); await super.exec(cs);
this.ep = ep; this.ep = ep;
@@ -34,7 +34,7 @@ class Rasa extends Task {
const {span, ctx} = this.startChildSpan(`nested:${this.gatherTask.summary}`); const {span, ctx} = this.startChildSpan(`nested:${this.gatherTask.summary}`);
this.gatherTask.span = span; this.gatherTask.span = span;
this.gatherTask.ctx = ctx; this.gatherTask.ctx = ctx;
this.gatherTask.exec(cs, ep, this) this.gatherTask.exec(cs, {ep})
.then(() => span.end()) .then(() => span.end())
.catch((err) => { .catch((err) => {
span.end(); span.end();
@@ -128,7 +128,7 @@ class Rasa extends Task {
const {span, ctx} = this.startChildSpan(`nested:${this.gatherTask.summary}`); const {span, ctx} = this.startChildSpan(`nested:${this.gatherTask.summary}`);
this.gatherTask.span = span; this.gatherTask.span = span;
this.gatherTask.ctx = ctx; this.gatherTask.ctx = ctx;
this.gatherTask.exec(cs, ep, this) this.gatherTask.exec(cs, {ep})
.then(() => span.end()) .then(() => span.end())
.catch((err) => { .catch((err) => {
span.end(); span.end();

View File

@@ -1,7 +1,7 @@
const Task = require('./task'); const Task = require('./task');
const {TaskName} = require('../utils/constants'); const {TaskName} = require('../utils/constants');
const makeTask = require('./make_task'); const makeTask = require('./make_task');
const normalizeJambones = require('../utils/normalize-jambones'); const { normalizeJambones } = require('@jambonz/verb-specifications');
/** /**
* Manages an outdial made via REST API * Manages an outdial made via REST API
@@ -11,6 +11,7 @@ class TaskRestDial extends Task {
super(logger, opts); super(logger, opts);
this.from = this.data.from; this.from = this.data.from;
this.fromHost = this.data.fromHost;
this.to = this.data.to; this.to = this.data.to;
this.call_hook = this.data.call_hook; this.call_hook = this.data.call_hook;
this.timeout = this.data.timeout || 60; this.timeout = this.data.timeout || 60;
@@ -26,7 +27,7 @@ class TaskRestDial extends Task {
*/ */
async exec(cs) { async exec(cs) {
await super.exec(cs); await super.exec(cs);
this.req = cs.req; this.canCancel = true;
this._setCallTimer(); this._setCallTimer();
await this.awaitTaskDone(); await this.awaitTaskDone();
@@ -35,15 +36,15 @@ class TaskRestDial extends Task {
kill(cs) { kill(cs) {
super.kill(cs); super.kill(cs);
this._clearCallTimer(); this._clearCallTimer();
if (this.req) { if (this.canCancel && cs?.req) {
this.req.cancel(); this.canCancel = false;
this.req = null; cs.req.cancel();
} }
this.notifyTaskDone(); this.notifyTaskDone();
} }
async _onConnect(dlg) { async _onConnect(dlg) {
this.req = null; this.canCancel = false;
const cs = this.callSession; const cs = this.callSession;
cs.setDialog(dlg); cs.setDialog(dlg);
@@ -78,7 +79,7 @@ class TaskRestDial extends Task {
_onCallStatus(status) { _onCallStatus(status) {
this.logger.debug(`CallStatus: ${status}`); this.logger.debug(`CallStatus: ${status}`);
if (status >= 200) { if (status >= 200) {
this.req = null; this.canCancel = false;
this._clearCallTimer(); this._clearCallTimer();
if (status !== 200) this.notifyTaskDone(); if (status !== 200) this.notifyTaskDone();
} }

View File

@@ -23,7 +23,7 @@ class TaskSayLegacy extends Task {
get name() { return TaskName.SayLegacy; } get name() { return TaskName.SayLegacy; }
async exec(cs, ep) { async exec(cs, {ep}) {
super.exec(cs); super.exec(cs);
this.ep = ep; this.ep = ep;
try { try {

View File

@@ -1,15 +1,41 @@
const Task = require('./task'); const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants'); const {TaskName, TaskPreconditions} = require('../utils/constants');
const pollySSMLSplit = require('polly-ssml-split');
const breakLengthyTextIfNeeded = (logger, text) => {
const chunkSize = 1000;
const isSSML = text.startsWith('<speak>');
if (text.length <= chunkSize || !isSSML) return [text];
const options = {
// MIN length
softLimit: 100,
// MAX length, exclude 15 characters <speak></speak>
hardLimit: chunkSize - 15,
// Set of extra split characters (Optional property)
extraSplitChars: ',;!?',
};
pollySSMLSplit.configure(options);
try {
return pollySSMLSplit.split(text);
} catch (err) {
logger.info({err}, 'Error spliting SSML long text');
return [text];
}
};
class TaskSay extends Task { class TaskSay extends Task {
constructor(logger, opts, parentTask) { constructor(logger, opts, parentTask) {
super(logger, opts); super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint; this.preconditions = TaskPreconditions.Endpoint;
this.text = Array.isArray(this.data.text) ? this.data.text : [this.data.text]; this.text = (Array.isArray(this.data.text) ? this.data.text : [this.data.text])
.map((t) => breakLengthyTextIfNeeded(this.logger, t))
.flat();
this.loop = this.data.loop || 1; this.loop = this.data.loop || 1;
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia); this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
this.synthesizer = this.data.synthesizer || {}; this.synthesizer = this.data.synthesizer || {};
this.disableTtsCache = this.data.disableTtsCache;
} }
get name() { return TaskName.Say; } get name() { return TaskName.Say; }
@@ -22,7 +48,7 @@ class TaskSay extends Task {
return `${this.name}{${this.text[0]}}`; return `${this.name}{${this.text[0]}}`;
} }
async exec(cs, ep) { async exec(cs, {ep}) {
await super.exec(cs); await super.exec(cs);
const {srf} = cs; const {srf} = cs;
@@ -35,14 +61,24 @@ class TaskSay extends Task {
const language = this.synthesizer.language && this.synthesizer.language !== 'default' ? const language = this.synthesizer.language && this.synthesizer.language !== 'default' ?
this.synthesizer.language : this.synthesizer.language :
cs.speechSynthesisLanguage ; cs.speechSynthesisLanguage ;
const voice = this.synthesizer.voice && this.synthesizer.voice !== 'default' ? let voice = this.synthesizer.voice && this.synthesizer.voice !== 'default' ?
this.synthesizer.voice : this.synthesizer.voice :
cs.speechSynthesisVoice; cs.speechSynthesisVoice;
const engine = this.synthesizer.engine || 'standard'; const engine = this.synthesizer.engine || 'standard';
const salt = cs.callSid; const salt = cs.callSid;
const credentials = cs.getSpeechCredentials(vendor, 'tts'); const credentials = cs.getSpeechCredentials(vendor, 'tts');
this.logger.info({vendor, language, voice}, 'TaskSay:exec'); /* parse Nuance voices into name and model */
let model;
if (vendor === 'nuance' && voice) {
const arr = /([A-Za-z-]*)\s+-\s+(enhanced|standard)/.exec(voice);
if (arr) {
voice = arr[1];
model = arr[2];
}
}
this.logger.info({vendor, language, voice, model}, 'TaskSay:exec');
this.ep = ep; this.ep = ep;
try { try {
if (!credentials) { if (!credentials) {
@@ -51,7 +87,10 @@ class TaskSay extends Task {
alert_type: AlertType.TTS_NOT_PROVISIONED, alert_type: AlertType.TTS_NOT_PROVISIONED,
vendor vendor
}).catch((err) => this.logger.info({err}, 'Error generating alert for no tts')); }).catch((err) => this.logger.info({err}, 'Error generating alert for no tts'));
this.notifyError(`No speech credentials have been provisioned for ${vendor}`); this.notifyError({
msg: 'TTS error',
details:`No speech credentials provisioned for selected vendor ${vendor}`
});
throw new Error('no provisioned speech credentials for TTS'); throw new Error('no provisioned speech credentials for TTS');
} }
// synthesize all of the text elements // synthesize all of the text elements
@@ -69,14 +108,16 @@ class TaskSay extends Task {
'tts.voice': voice 'tts.voice': voice
}); });
try { try {
const {filePath, servedFromCache} = await synthAudio(stats, { const {filePath, servedFromCache, rtt} = await synthAudio(stats, {
text, text,
vendor, vendor,
language, language,
voice, voice,
engine, engine,
model,
salt, salt,
credentials credentials,
disableTtsCache : this.disableTtsCache
}); });
this.logger.debug(`file ${filePath}, served from cache ${servedFromCache}`); this.logger.debug(`file ${filePath}, served from cache ${servedFromCache}`);
if (filePath) cs.trackTmpFile(filePath); if (filePath) cs.trackTmpFile(filePath);
@@ -87,24 +128,33 @@ class TaskSay extends Task {
} }
span.setAttributes({'tts.cached': servedFromCache}); span.setAttributes({'tts.cached': servedFromCache});
span.end(); span.end();
if (!servedFromCache && rtt) {
this.notifyStatus({
event: 'synthesized-audio',
vendor,
language,
characters: text.length,
elapsedTime: rtt
});
}
return filePath; return filePath;
} catch (err) { } catch (err) {
this.logger.info({err}, 'Error synthesizing tts'); this.logger.info({err}, 'Error synthesizing tts');
span.end(); span.end();
writeAlerts({ writeAlerts({
account_sid: cs.accountSid, account_sid: cs.accountSid,
alert_type: AlertType.TTS_NOT_PROVISIONED, alert_type: AlertType.TTS_FAILURE,
vendor, vendor,
detail: err.message detail: err.message
}).catch((err) => this.logger.info({err}, 'Error generating alert for tts failure')); }).catch((err) => this.logger.info({err}, 'Error generating alert for tts failure'));
this.notifyError(err.message || err); this.notifyError({msg: 'TTS error', details: err.message || err});
return; return;
} }
}; };
const arr = this.text.map((t) => generateAudio(t)); const arr = this.text.map((t) => generateAudio(t));
const filepath = (await Promise.all(arr)).filter((fp) => fp && fp.length); const filepath = (await Promise.all(arr)).filter((fp) => fp && fp.length);
this.logger.debug({filepath}, 'synthesized files for tts'); this.notifyStatus({event: 'start-playback'});
while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep?.connected) { while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep?.connected) {
let segment = 0; let segment = 0;
@@ -136,6 +186,7 @@ class TaskSay extends Task {
this.killPlayToConfMember(this.ep, memberId, confName); this.killPlayToConfMember(this.ep, memberId, confName);
} }
else { else {
this.notifyStatus({event: 'kill-playback'});
this.ep.api('uuid_break', this.ep.uuid); this.ep.api('uuid_break', this.ep.uuid);
} }
} }

View File

@@ -36,6 +36,7 @@ class TaskSipRefer extends Task {
method: 'REFER', method: 'REFER',
headers: { headers: {
...this.headers, ...this.headers,
...(this.referToIsUri && {'X-Refer-To-Leave-Untouched': true}),
'Refer-To': referTo, 'Refer-To': referTo,
'Referred-By': referredBy '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 we fail, fall through to next verb. If success, we should get BYE from far end */
if (this.referStatus === 202) { 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(); await this.awaitTaskDone();
if (this._notifyTimer) {
clearTimeout(this._notifyTimer);
this._notifyTimer = null;
}
} }
else { else {
await this.performAction({refer_status: this.referStatus}); await this.performAction({refer_status: this.referStatus});
@@ -70,10 +81,10 @@ class TaskSipRefer extends Task {
const contentType = req.get('Content-Type'); const contentType = req.get('Content-Type');
this.logger.debug({body: req.body}, `TaskSipRefer:_handleNotify got ${contentType}`); 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); const arr = /SIP\/2\.0\s+(\d+)/.exec(req.body);
if (arr) { 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}`); this.logger.debug(`TaskSipRefer:_handleNotify: call got status ${status}`);
if (this.eventHook) { if (this.eventHook) {
const b3 = this.getTracingPropagation(); const b3 = this.getTracingPropagation();
@@ -100,6 +111,7 @@ class TaskSipRefer extends Task {
/* they may have only provided a phone number/user */ /* they may have only provided a phone number/user */
referTo = `sip:${referTo}@${host}`; referTo = `sip:${referTo}@${host}`;
} }
else this.referToIsUri = true;
if (!referredBy) { if (!referredBy) {
/* default */ /* default */
referredBy = cs.req?.callingNumber || dlg.local.uri; referredBy = cs.req?.callingNumber || dlg.local.uri;

View File

@@ -17,7 +17,7 @@ class TaskSipRequest extends Task {
get name() { return TaskName.SipRequest; } get name() { return TaskName.SipRequest; }
async exec(cs, dlg) { async exec(cs, {dlg}) {
super.exec(cs); super.exec(cs);
try { try {
this.logger.info({dlg}, `TaskSipRequest: sending a SIP ${this.method}`); this.logger.info({dlg}, `TaskSipRequest: sending a SIP ${this.method}`);

View File

@@ -1,542 +0,0 @@
{
"sip:decline": {
"properties": {
"status": "number",
"reason": "string",
"headers": "object"
},
"required": [
"status"
]
},
"sip:request": {
"properties": {
"method": "string",
"body": "string",
"headers": "object",
"actionHook": "object|string"
},
"required": [
"method"
]
},
"sip:refer": {
"properties": {
"referTo": "string",
"referredBy": "string",
"headers": "object",
"actionHook": "object|string",
"eventHook": "object|string"
},
"required": [
"referTo"
]
},
"config": {
"properties": {
"synthesizer": "#synthesizer",
"recognizer": "#recognizer",
"bargeIn": "#bargeIn",
"record": "#recordOptions",
"amd": "#amd"
},
"required": []
},
"bargeIn": {
"properties": {
"enable": "boolean",
"sticky": "boolean",
"actionHook": "object|string",
"input": "array",
"finishOnKey": "string",
"numDigits": "number",
"minDigits": "number",
"maxDigits": "number",
"interDigitTimeout": "number",
"dtmfBargein": "boolean",
"minBargeinWordCount": "number"
},
"required": [
"enable"
]
},
"dequeue": {
"properties": {
"name": "string",
"actionHook": "object|string",
"timeout": "number",
"beep": "boolean"
},
"required": [
"name"
]
},
"enqueue": {
"properties": {
"name": "string",
"actionHook": "object|string",
"waitHook": "object|string",
"_": "object"
},
"required": [
"name"
]
},
"leave": {
"properties": {
}
},
"hangup": {
"properties": {
"headers": "object"
},
"required": [
]
},
"play": {
"properties": {
"url": "string",
"loop": "number|string",
"earlyMedia": "boolean"
},
"required": [
"url"
]
},
"say": {
"properties": {
"text": "string|array",
"loop": "number|string",
"synthesizer": "#synthesizer",
"earlyMedia": "boolean"
},
"required": [
"text"
]
},
"gather": {
"properties": {
"actionHook": "object|string",
"finishOnKey": "string",
"input": "array",
"numDigits": "number",
"minDigits": "number",
"maxDigits": "number",
"interDigitTimeout": "number",
"partialResultHook": "object|string",
"speechTimeout": "number",
"listenDuringPrompt": "boolean",
"dtmfBargein": "boolean",
"bargein": "boolean",
"minBargeinWordCount": "number",
"timeout": "number",
"recognizer": "#recognizer",
"play": "#play",
"say": "#say"
},
"required": [
]
},
"conference": {
"properties": {
"name": "string",
"beep": "boolean",
"startConferenceOnEnter": "boolean",
"endConferenceOnExit": "boolean",
"maxParticipants": "number",
"joinMuted": "boolean",
"actionHook": "object|string",
"waitHook": "object|string",
"statusEvents": "array",
"statusHook": "object|string",
"enterHook": "object|string",
"record": "#record"
},
"required": [
"name"
]
},
"dial": {
"properties": {
"actionHook": "object|string",
"answerOnBridge": "boolean",
"callerId": "string",
"confirmHook": "object|string",
"referHook": "object|string",
"dialMusic": "string",
"dtmfCapture": "object",
"dtmfHook": "object|string",
"headers": "object",
"listen": "#listen",
"target": ["#target"],
"timeLimit": "number",
"timeout": "number",
"proxy": "string",
"transcribe": "#transcribe",
"amd": "#amd"
},
"required": [
"target"
]
},
"dialogflow": {
"properties": {
"credentials": "object|string",
"project": "string",
"environment": "string",
"region": {
"type": "string",
"enum": ["europe-west1", "europe-west2", "australia-southeast1", "asia-northeast1"]
},
"lang": "string",
"actionHook": "object|string",
"eventHook": "object|string",
"events": "[string]",
"welcomeEvent": "string",
"welcomeEventParams": "object",
"noInputTimeout": "number",
"noInputEvent": "string",
"passDtmfAsTextInput": "boolean",
"thinkingMusic": "string",
"tts": "#synthesizer",
"bargein": "boolean"
},
"required": [
"project",
"credentials",
"lang"
]
},
"dtmf": {
"properties": {
"dtmf": "string",
"duration": "number"
},
"required": [
"dtmf"
]
},
"lex": {
"properties": {
"botId": "string",
"botAlias": "string",
"credentials": "object",
"region": "string",
"locale": "string",
"intent": "#lexIntent",
"welcomeMessage": "string",
"metadata": "object",
"bargein": "boolean",
"passDtmf": "boolean",
"actionHook": "object|string",
"eventHook": "object|string",
"noInputTimeout": "number",
"tts": "#synthesizer"
},
"required": [
"botId",
"botAlias",
"region",
"credentials"
]
},
"listen": {
"properties": {
"actionHook": "object|string",
"auth": "#auth",
"finishOnKey": "string",
"maxLength": "number",
"metadata": "object",
"mixType": {
"type": "string",
"enum": ["mono", "stereo", "mixed"]
},
"passDtmf": "boolean",
"playBeep": "boolean",
"sampleRate": "number",
"timeout": "number",
"transcribe": "#transcribe",
"url": "string",
"wsAuth": "#auth",
"earlyMedia": "boolean"
},
"required": [
"url"
]
},
"message": {
"properties": {
"carrier": "string",
"account_sid": "string",
"message_sid": "string",
"to": "string",
"from": "string",
"text": "string",
"media": "string|array",
"actionHook": "object|string"
},
"required": [
"to",
"from"
]
},
"pause": {
"properties": {
"length": "number"
},
"required": [
"length"
]
},
"rasa": {
"properties": {
"url": "string",
"recognizer": "#recognizer",
"tts": "#synthesizer",
"prompt": "string",
"actionHook": "object|string",
"eventHook": "object|string"
},
"required": [
"url"
]
},
"record": {
"properties": {
"path": "string"
},
"required": [
"path"
]
},
"recordOptions": {
"properties": {
"action": {
"type": "string",
"enum": ["startCallRecording", "stopCallRecording", "pauseCallRecording", "resumeCallRecording"]
},
"recordingID": "string",
"siprecServerURL": "string"
},
"required": [
"action"
]
},
"redirect": {
"properties": {
"actionHook": "object|string"
},
"required": [
"actionHook"
]
},
"rest:dial": {
"properties": {
"account_sid": "string",
"application_sid": "string",
"call_hook": "object|string",
"call_status_hook": "object|string",
"from": "string",
"speech_synthesis_vendor": "string",
"speech_synthesis_voice": "string",
"speech_synthesis_language": "string",
"speech_recognizer_vendor": "string",
"speech_recognizer_language": "string",
"tag": "object",
"to": "#target",
"headers": "object",
"timeout": "number"
},
"required": [
"call_hook",
"from",
"to"
]
},
"tag": {
"properties": {
"data": "object"
},
"required": [
"data"
]
},
"transcribe": {
"properties": {
"transcriptionHook": "string",
"recognizer": "#recognizer",
"earlyMedia": "boolean"
},
"required": [
"recognizer"
]
},
"target": {
"properties": {
"type": {
"type": "string",
"enum": ["phone", "sip", "user", "teams"]
},
"confirmHook": "object|string",
"method": {
"type": "string",
"enum": ["GET", "POST"]
},
"headers": "object",
"name": "string",
"number": "string",
"sipUri": "string",
"auth": "#auth",
"vmail": "boolean",
"tenant": "string",
"trunk": "string",
"overrideTo": "string"
},
"required": [
"type"
]
},
"auth": {
"properties": {
"username": "string",
"password": "string"
},
"required": [
"username",
"password"
]
},
"synthesizer": {
"properties": {
"vendor": {
"type": "string",
"enum": ["google", "aws", "polly", "microsoft", "default"]
},
"language": "string",
"voice": "string",
"engine": {
"type": "string",
"enum": ["standard", "neural"]
},
"gender": {
"type": "string",
"enum": ["MALE", "FEMALE", "NEUTRAL"]
}
},
"required": [
"vendor"
]
},
"recognizer": {
"properties": {
"vendor": {
"type": "string",
"enum": ["google", "aws", "microsoft", "default"]
},
"language": "string",
"vad": "#vad",
"hints": "array",
"hintsBoost": "number",
"altLanguages": "array",
"profanityFilter": "boolean",
"interim": "boolean",
"singleUtterance": "boolean",
"dualChannel": "boolean",
"separateRecognitionPerChannel": "boolean",
"punctuation": "boolean",
"enhancedModel": "boolean",
"words": "boolean",
"diarization": "boolean",
"diarizationMinSpeakers": "number",
"diarizationMaxSpeakers": "number",
"interactionType": {
"type": "string",
"enum": [
"unspecified",
"discussion",
"presentation",
"phone_call",
"voicemail",
"voice_search",
"voice_command",
"dictation"
]
},
"naicsCode": "number",
"identifyChannels": "boolean",
"vocabularyName": "string",
"vocabularyFilterName": "string",
"filterMethod": {
"type": "string",
"enum": [
"remove",
"mask",
"tag"
]
},
"model": "string",
"outputFormat": {
"type": "string",
"enum": [
"simple",
"detailed"
]
},
"profanityOption": {
"type": "string",
"enum": [
"masked",
"removed",
"raw"
]
},
"requestSnr": "boolean",
"initialSpeechTimeoutMs": "number",
"azureServiceEndpoint": "string",
"asrDtmfTerminationDigit": "string",
"asrTimeout": "number"
},
"required": [
"vendor"
]
},
"lexIntent": {
"properties": {
"name": "string",
"slots": "object"
},
"required": [
"name"
]
},
"vad": {
"properties": {
"enable": "boolean",
"voiceMs": "number",
"mode": "number"
},
"required": [
"enable"
]
},
"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,13 +1,10 @@
const Emitter = require('events'); 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'); const {TaskPreconditions} = require('../utils/constants');
const normalizeJambones = require('../utils/normalize-jambones'); const { normalizeJambones } = require('@jambonz/verb-specifications');
const WsRequestor = require('../utils/ws-requestor');
const {TaskName} = require('../utils/constants');
const {trace} = require('@opentelemetry/api'); const {trace} = require('@opentelemetry/api');
const specs = new Map();
const _specData = require('./specs');
for (const key in _specData) {specs.set(key, _specData[key]);}
/** /**
* @classdesc Represents a jambonz verb. This is a superclass that is extended * @classdesc Represents a jambonz verb. This is a superclass that is extended
@@ -21,6 +18,7 @@ class Task extends Emitter {
this.logger = logger; this.logger = logger;
this.data = data; this.data = data;
this.actionHook = this.data.actionHook; this.actionHook = this.data.actionHook;
this.id = data.id;
this._killInProgress = false; this._killInProgress = false;
this._completionPromise = new Promise((resolve) => this._completionResolver = resolve); this._completionPromise = new Promise((resolve) => this._completionResolver = resolve);
@@ -137,21 +135,32 @@ class Task extends Emitter {
return this.callSession.normalizeUrl(url, method, auth); return this.callSession.normalizeUrl(url, method, auth);
} }
notifyError(errMsg) { notifyError(obj) {
const params = {error: errMsg, verb: this.name}; if (this.cs.requestor instanceof WsRequestor) {
this.cs.requestor.request('jambonz:error', '/error', params) const params = {...obj, verb: this.name, id: this.id};
.catch((err) => this.logger.info({err}, 'Task:notifyError error sending error')); this.cs.requestor.request('jambonz:error', '/error', params)
.catch((err) => this.logger.info({err}, 'Task:notifyError error sending error'));
}
}
notifyStatus(obj) {
if (this.cs.notifyEvents && this.cs.requestor instanceof WsRequestor) {
const params = {...obj, verb: this.name, id: this.id};
this.cs.requestor.request('verb:status', '/status', params)
.catch((err) => this.logger.info({err}, 'Task:notifyStatus error sending error'));
}
} }
async performAction(results, expectResponse = true) { async performAction(results, expectResponse = true) {
if (this.actionHook) { if (this.actionHook) {
const type = this.name === TaskName.Redirect ? 'session:redirect' : 'verb:hook';
const params = results ? Object.assign(this.cs.callInfo.toJSON(), results) : 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 span = this.startSpan(type, {'hook.url': this.actionHook});
const b3 = this.getTracingPropagation('b3', span); const b3 = this.getTracingPropagation('b3', span);
const httpHeaders = b3 && {b3}; const httpHeaders = b3 && {b3};
span.setAttributes({'http.body': JSON.stringify(params)}); span.setAttributes({'http.body': JSON.stringify(params)});
try { try {
const json = await this.cs.requestor.request('verb:hook', this.actionHook, params, httpHeaders); const json = await this.cs.requestor.request(type, this.actionHook, params, httpHeaders);
span.setAttributes({'http.statusCode': 200}); span.setAttributes({'http.statusCode': 200});
span.end(); span.end();
if (expectResponse && json && Array.isArray(json)) { if (expectResponse && json && Array.isArray(json)) {
@@ -273,74 +282,6 @@ class Task extends Emitter {
this.logger.error(err, 'Task:_doRefer error'); this.logger.error(err, 'Task:_doRefer error');
} }
} }
/**
* validate that the JSON task description is valid
* @param {string} name - verb name
* @param {object} data - verb properties
*/
static validate(name, data) {
debug(`validating ${name} with data ${JSON.stringify(data)}`);
// validate the instruction is supported
if (!specs.has(name)) throw new Error(`invalid instruction: ${name}`);
// check type of each element and make sure required elements are present
const specData = specs.get(name);
let required = specData.required || [];
for (const dKey in data) {
if (dKey in specData.properties) {
const dVal = data[dKey];
const dSpec = specData.properties[dKey];
debug(`Task:validate validating property ${dKey} with value ${JSON.stringify(dVal)}`);
if (typeof dSpec === 'string' && dSpec === 'array') {
if (!Array.isArray(dVal)) throw new Error(`${name}: property ${dKey} is not an array`);
}
else if (typeof dSpec === 'string' && dSpec.includes('|')) {
const types = dSpec.split('|').map((t) => t.trim());
if (!types.includes(typeof dVal) && !(types.includes('array') && Array.isArray(dVal))) {
throw new Error(`${name}: property ${dKey} has invalid data type, must be one of ${types}`);
}
}
else if (typeof dSpec === 'string' && ['number', 'string', 'object', 'boolean'].includes(dSpec)) {
// simple types
if (typeof dVal !== specData.properties[dKey]) {
throw new Error(`${name}: property ${dKey} has invalid data type`);
}
}
else if (Array.isArray(dSpec) && dSpec[0].startsWith('#')) {
const name = dSpec[0].slice(1);
for (const item of dVal) {
Task.validate(name, item);
}
}
else if (typeof dSpec === 'object') {
// complex types
const type = dSpec.type;
assert.ok(['number', 'string', 'object', 'boolean'].includes(type),
`invalid or missing type in spec ${JSON.stringify(dSpec)}`);
if (type === 'string' && dSpec.enum) {
assert.ok(Array.isArray(dSpec.enum), `enum must be an array ${JSON.stringify(dSpec.enum)}`);
if (!dSpec.enum.includes(dVal)) throw new Error(`invalid value ${dVal} must be one of ${dSpec.enum}`);
}
}
else if (typeof dSpec === 'string' && dSpec.startsWith('#')) {
// reference to another datatype (i.e. nested type)
const name = dSpec.slice(1);
//const obj = {};
//obj[name] = dVal;
Task.validate(name, dVal);
}
else {
assert.ok(0, `invalid spec ${JSON.stringify(dSpec)}`);
}
required = required.filter((item) => item !== dKey);
}
else throw new Error(`${name}: unknown property ${dKey}`);
}
if (required.length > 0) throw new Error(`${name}: missing value for ${required}`);
}
} }
module.exports = Task; module.exports = Task;

View File

@@ -3,9 +3,16 @@ const {
TaskName, TaskName,
TaskPreconditions, TaskPreconditions,
GoogleTranscriptionEvents, GoogleTranscriptionEvents,
NuanceTranscriptionEvents,
AwsTranscriptionEvents,
AzureTranscriptionEvents, AzureTranscriptionEvents,
AwsTranscriptionEvents DeepgramTranscriptionEvents,
SonioxTranscriptionEvents,
IbmTranscriptionEvents,
NvidiaTranscriptionEvents,
JambonzTranscriptionEvents
} = require('../utils/constants'); } = require('../utils/constants');
const { normalizeJambones } = require('@jambonz/verb-specifications');
class TaskTranscribe extends Task { class TaskTranscribe extends Task {
constructor(logger, opts, parentTask) { constructor(logger, opts, parentTask) {
@@ -13,6 +20,18 @@ class TaskTranscribe extends Task {
this.preconditions = TaskPreconditions.Endpoint; this.preconditions = TaskPreconditions.Endpoint;
this.parentTask = parentTask; this.parentTask = parentTask;
const {
setChannelVarsForStt,
normalizeTranscription,
removeSpeechListeners,
setSpeechCredentialsAtRuntime,
compileSonioxTranscripts
} = require('../utils/transcription-utils')(logger);
this.setChannelVarsForStt = setChannelVarsForStt;
this.normalizeTranscription = normalizeTranscription;
this.removeSpeechListeners = removeSpeechListeners;
this.compileSonioxTranscripts = compileSonioxTranscripts;
this.transcriptionHook = this.data.transcriptionHook; this.transcriptionHook = this.data.transcriptionHook;
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia); this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
@@ -22,51 +41,53 @@ class TaskTranscribe extends Task {
this.interim = !!recognizer.interim; this.interim = !!recognizer.interim;
this.separateRecognitionPerChannel = recognizer.separateRecognitionPerChannel; this.separateRecognitionPerChannel = recognizer.separateRecognitionPerChannel;
/* vad: if provided, we dont connect to recognizer until voice activity is detected */ /* let credentials be supplied in the recognizer object at runtime */
const {enable, voiceMs = 0, mode = -1} = recognizer.vad || {}; this.sttCredentials = setSpeechCredentialsAtRuntime(recognizer);
this.vad = {enable, voiceMs, mode};
/* google-specific options */ /* buffer for soniox transcripts */
this.hints = recognizer.hints || []; this._sonioxTranscripts = [];
this.hintsBoost = recognizer.hintsBoost;
this.profanityFilter = recognizer.profanityFilter;
this.punctuation = !!recognizer.punctuation;
this.enhancedModel = !!recognizer.enhancedModel;
this.model = recognizer.model || 'phone_call';
this.words = !!recognizer.words;
this.singleUtterance = recognizer.singleUtterance || false;
this.diarization = !!recognizer.diarization;
this.diarizationMinSpeakers = recognizer.diarizationMinSpeakers || 0;
this.diarizationMaxSpeakers = recognizer.diarizationMaxSpeakers || 0;
this.interactionType = recognizer.interactionType || 'unspecified';
this.naicsCode = recognizer.naicsCode || 0;
this.altLanguages = recognizer.altLanguages || [];
/* aws-specific options */ recognizer.hints = recognizer.hints || [];
this.identifyChannels = !!recognizer.identifyChannels; recognizer.altLanguages = recognizer.altLanguages || [];
this.vocabularyName = recognizer.vocabularyName;
this.vocabularyFilterName = recognizer.vocabularyFilterName;
this.filterMethod = recognizer.filterMethod;
/* microsoft options */
this.outputFormat = recognizer.outputFormat || 'simple';
this.profanityOption = recognizer.profanityOption || 'raw';
this.requestSnr = recognizer.requestSnr || false;
this.initialSpeechTimeoutMs = recognizer.initialSpeechTimeoutMs || 0;
this.azureServiceEndpoint = recognizer.azureServiceEndpoint;
} }
get name() { return TaskName.Transcribe; } get name() { return TaskName.Transcribe; }
async exec(cs, ep, ep2) { async exec(cs, {ep, ep2}) {
super.exec(cs); super.exec(cs);
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf); const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
const {getNuanceAccessToken, getIbmAccessToken} = cs.srf.locals.dbHelpers;
if (cs.hasGlobalSttHints) {
const {hints, hintsBoost} = cs.globalSttHints;
this.data.recognizer.hints = this.data.recognizer.hints.concat(hints);
if (!this.data.recognizer.hintsBoost && hintsBoost) this.data.recognizer.hintsBoost = hintsBoost;
this.logger.debug({hints: this.data.recognizer.hints, hintsBoost: this.data.recognizer.hintsBoost},
'Transcribe:exec - applying global sttHints');
}
if (cs.hasAltLanguages) {
this.data.recognizer.altLanguages = this.data.recognizer.altLanguages.concat(cs.altLanguages);
this.logger.debug({altLanguages: this.altLanguages},
'Transcribe:exec - applying altLanguages');
}
if (cs.hasGlobalSttPunctuation && !this.data.recognizer.punctuation) {
this.data.recognizer.punctuation = cs.globalSttPunctuation;
}
this.ep = ep; this.ep = ep;
this.ep2 = ep2; this.ep2 = ep2;
if ('default' === this.vendor || !this.vendor) this.vendor = cs.speechRecognizerVendor; if ('default' === this.vendor || !this.vendor) {
if ('default' === this.language || !this.language) this.language = cs.speechRecognizerLanguage; this.vendor = cs.speechRecognizerVendor;
this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt'); if (this.data.recognizer) this.data.recognizer.vendor = this.vendor;
}
if ('default' === this.language || !this.language) {
this.language = cs.speechRecognizerLanguage;
if (this.data.recognizer) this.data.recognizer.language = this.language;
}
if (!this.data.recognizer.vendor) {
this.data.recognizer.vendor = this.vendor;
}
if (!this.sttCredentials) this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt');
try { try {
if (!this.sttCredentials) { if (!this.sttCredentials) {
@@ -79,8 +100,26 @@ class TaskTranscribe extends Task {
}).catch((err) => this.logger.info({err}, 'Error generating alert for no stt')); }).catch((err) => this.logger.info({err}, 'Error generating alert for no stt'));
throw new Error('no provisioned speech credentials for TTS'); throw new Error('no provisioned speech credentials for TTS');
} }
if (this.vendor === 'nuance' && this.sttCredentials.client_id) {
/* get nuance access token */
const {client_id, secret} = this.sttCredentials;
const {access_token, servedFromCache} = await getNuanceAccessToken(client_id, secret, 'asr tts');
this.logger.debug({client_id},
`Transcribe:exec - got nuance access token ${servedFromCache ? 'from cache' : ''}`);
this.sttCredentials = {...this.sttCredentials, access_token};
}
else if (this.vendor == 'ibm' && this.sttCredentials.stt_api_key) {
/* get ibm access token */
const {stt_api_key, stt_region} = this.sttCredentials;
const {access_token, servedFromCache} = await getIbmAccessToken(stt_api_key);
this.logger.debug({stt_api_key}, `Gather:exec - got ibm access token ${servedFromCache ? 'from cache' : ''}`);
this.sttCredentials = {...this.sttCredentials, access_token, stt_region};
}
await this._startTranscribing(cs, ep, 1); 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) updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid)
.catch(() => {/*already logged error */}); .catch(() => {/*already logged error */});
@@ -90,142 +129,112 @@ class TaskTranscribe extends Task {
this.logger.info(err, 'TaskTranscribe:exec - error'); this.logger.info(err, 'TaskTranscribe:exec - error');
this.parentTask && this.parentTask.emit('error', err); this.parentTask && this.parentTask.emit('error', err);
} }
ep.removeCustomEventListener(GoogleTranscriptionEvents.Transcription); this.removeSpeechListeners(ep);
ep.removeCustomEventListener(GoogleTranscriptionEvents.NoAudioDetected);
ep.removeCustomEventListener(GoogleTranscriptionEvents.MaxDurationExceeded);
ep.removeCustomEventListener(AwsTranscriptionEvents.Transcription);
ep.removeCustomEventListener(AwsTranscriptionEvents.NoAudioDetected);
ep.removeCustomEventListener(AwsTranscriptionEvents.MaxDurationExceeded);
ep.removeCustomEventListener(AzureTranscriptionEvents.Transcription);
ep.removeCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected);
} }
async kill(cs) { async kill(cs) {
super.kill(cs); super.kill(cs);
let stopTranscription = false;
if (this.ep?.connected) { if (this.ep?.connected) {
stopTranscription = true;
this.ep.stopTranscription({vendor: this.vendor}) this.ep.stopTranscription({vendor: this.vendor})
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill')); .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) { if (this.separateRecognitionPerChannel && this.ep2 && this.ep2.connected) {
stopTranscription = true;
this.ep2.stopTranscription({vendor: this.vendor}) this.ep2.stopTranscription({vendor: this.vendor})
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill')); .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(); else this.notifyTaskDone();
await this.awaitTaskDone(); await this.awaitTaskDone();
} }
async _startTranscribing(cs, ep, channel) { async _startTranscribing(cs, ep, channel) {
const opts = {}; const opts = this.setChannelVarsForStt(this, this.sttCredentials, this.data.recognizer);
switch (this.vendor) {
case 'google':
this.bugname = 'google_transcribe';
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep, channel));
ep.addCustomEventListener(GoogleTranscriptionEvents.NoAudioDetected,
this._onNoAudio.bind(this, cs, ep, channel));
ep.addCustomEventListener(GoogleTranscriptionEvents.MaxDurationExceeded,
this._onMaxDurationExceeded.bind(this, cs, ep, channel));
break;
if (this.vad.enable) { case 'aws':
opts.START_RECOGNIZING_ON_VAD = 1; case 'polly':
if (this.vad.voiceMs) opts.RECOGNIZER_VAD_VOICE_MS = this.vad.voiceMs; this.bugname = 'aws_transcribe';
if (this.vad.mode >= 0 && this.vad.mode <= 3) opts.RECOGNIZER_VAD_MODE = this.vad.mode; ep.addCustomEventListener(AwsTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep, channel));
ep.addCustomEventListener(AwsTranscriptionEvents.NoAudioDetected,
this._onNoAudio.bind(this, cs, ep, channel));
ep.addCustomEventListener(AwsTranscriptionEvents.MaxDurationExceeded,
this._onMaxDurationExceeded.bind(this, cs, ep, channel));
break;
case 'microsoft':
this.bugname = 'azure_transcribe';
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep, channel));
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected,
this._onNoAudio.bind(this, cs, ep, channel));
break;
case 'nuance':
this.bugname = 'nuance_transcribe';
ep.addCustomEventListener(NuanceTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep, channel));
ep.addCustomEventListener(NuanceTranscriptionEvents.StartOfSpeech,
this._onStartOfSpeech.bind(this, cs, ep, channel));
ep.addCustomEventListener(NuanceTranscriptionEvents.TranscriptionComplete,
this._onTranscriptionComplete.bind(this, cs, ep, channel));
break;
case 'deepgram':
this.bugname = 'deepgram_transcribe';
ep.addCustomEventListener(DeepgramTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep, channel));
ep.addCustomEventListener(DeepgramTranscriptionEvents.Connect,
this._onDeepgramConnect.bind(this, cs, ep, channel));
ep.addCustomEventListener(DeepgramTranscriptionEvents.ConnectFailure,
this._onDeepGramConnectFailure.bind(this, cs, ep, channel));
break;
case 'soniox':
this.bugname = 'soniox_transcribe';
ep.addCustomEventListener(SonioxTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep, channel));
break;
case 'ibm':
this.bugname = 'ibm_transcribe';
ep.addCustomEventListener(IbmTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep, channel));
ep.addCustomEventListener(IbmTranscriptionEvents.Connect,
this._onIbmConnect.bind(this, cs, ep, channel));
ep.addCustomEventListener(IbmTranscriptionEvents.ConnectFailure,
this._onIbmConnectFailure.bind(this, cs, ep, channel));
break;
case 'nvidia':
this.bugname = 'nvidia_transcribe';
ep.addCustomEventListener(NvidiaTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(NvidiaTranscriptionEvents.StartOfSpeech,
this._onStartOfSpeech.bind(this, cs, ep));
ep.addCustomEventListener(NvidiaTranscriptionEvents.TranscriptionComplete,
this._onTranscriptionComplete.bind(this, cs, ep));
ep.addCustomEventListener(NvidiaTranscriptionEvents.VadDetected,
this._onVadDetected.bind(this, cs, ep));
break;
default:
throw new Error(`Invalid vendor ${this.vendor}`);
} }
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, /* common handler for all stt engine errors */
this._onTranscription.bind(this, cs, ep, channel)); ep.addCustomEventListener(JambonzTranscriptionEvents.Error, this._onJambonzError.bind(this, cs, ep));
ep.addCustomEventListener(GoogleTranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, cs, ep, channel)); await ep.set(opts)
ep.addCustomEventListener(GoogleTranscriptionEvents.MaxDurationExceeded, .catch((err) => this.logger.info(err, 'Error setting channel variables'));
this._onMaxDurationExceeded.bind(this, cs, ep, channel));
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep, channel));
ep.addCustomEventListener(AwsTranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, cs, ep, channel));
ep.addCustomEventListener(AwsTranscriptionEvents.MaxDurationExceeded,
this._onMaxDurationExceeded.bind(this, cs, ep, channel));
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep, channel));
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected, this._onNoAudio.bind(this, cs, ep, channel));
if (this.vendor === 'google') {
this.bugname = 'google_trancribe';
if (this.sttCredentials) opts.GOOGLE_APPLICATION_CREDENTIALS = JSON.stringify(this.sttCredentials.credentials);
[
['enhancedModel', 'GOOGLE_SPEECH_USE_ENHANCED'],
//['separateRecognitionPerChannel', 'GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL'],
['profanityFilter', 'GOOGLE_SPEECH_PROFANITY_FILTER'],
['punctuation', 'GOOGLE_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION'],
['words', 'GOOGLE_SPEECH_ENABLE_WORD_TIME_OFFSETS'],
['singleUtterance', 'GOOGLE_SPEECH_SINGLE_UTTERANCE'],
['diarization', 'GOOGLE_SPEECH_PROFANITY_FILTER']
].forEach((arr) => {
if (this[arr[0]]) opts[arr[1]] = true;
});
if (this.hints.length > 1) {
opts.GOOGLE_SPEECH_HINTS = this.hints.join(',');
if (typeof this.hintsBoost === 'number') {
opts.GOOGLE_SPEECH_HINTS_BOOST = this.hintsBoost;
}
}
if (this.altLanguages.length > 1) opts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
if ('unspecified' !== this.interactionType) {
opts.GOOGLE_SPEECH_METADATA_INTERACTION_TYPE = this.interactionType;
}
opts.GOOGLE_SPEECH_MODEL = this.model;
if (this.diarization && this.diarizationMinSpeakers > 0) {
opts.GOOGLE_SPEECH_SPEAKER_DIARIZATION_MIN_SPEAKER_COUNT = this.diarizationMinSpeakers;
}
if (this.diarization && this.diarizationMaxSpeakers > 0) {
opts.GOOGLE_SPEECH_SPEAKER_DIARIZATION_MAX_SPEAKER_COUNT = this.diarizationMaxSpeakers;
}
if (this.naicsCode > 0) opts.GOOGLE_SPEECH_METADATA_INDUSTRY_NAICS_CODE = this.naicsCode;
await ep.set(opts)
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with google'));
}
else if (this.vendor === 'aws') {
this.bugname = 'aws_trancribe';
[
['diarization', 'AWS_SHOW_SPEAKER_LABEL'],
['identifyChannels', 'AWS_ENABLE_CHANNEL_IDENTIFICATION']
].forEach((arr) => {
if (this[arr[0]]) opts[arr[1]] = true;
});
if (this.vocabularyName) opts.AWS_VOCABULARY_NAME = this.vocabularyName;
if (this.vocabularyFilterName) {
opts.AWS_VOCABULARY_NAME = this.vocabularyFilterName;
opts.AWS_VOCABULARY_FILTER_METHOD = this.filterMethod || 'mask';
}
if (this.sttCredentials) {
Object.assign(opts, {
AWS_ACCESS_KEY_ID: this.sttCredentials.accessKeyId,
AWS_SECRET_ACCESS_KEY: this.sttCredentials.secretAccessKey,
AWS_REGION: this.sttCredentials.region
});
}
else {
Object.assign(opts, {
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
AWS_REGION: process.env.AWS_REGION
});
}
await ep.set(opts)
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with aws'));
}
else if (this.vendor === 'microsoft') {
this.bugname = 'azure_trancribe';
Object.assign(opts, {
'AZURE_SUBSCRIPTION_KEY': this.sttCredentials.api_key,
'AZURE_REGION': this.sttCredentials.region
});
if (this.hints && this.hints.length > 1) {
opts.AZURE_SPEECH_HINTS = this.hints.map((h) => h.trim()).join(',');
}
if (this.altLanguages.length > 1) opts.AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
if (this.requestSnr) opts.AZURE_REQUEST_SNR = 1;
if (this.profanityOption !== 'raw') opts.AZURE_PROFANITY_OPTION = this.profanityOption;
if (this.initialSpeechTimeoutMs > 0) opts.AZURE_INITIAL_SPEECH_TIMEOUT_MS = this.initialSpeechTimeoutMs;
if (this.outputFormat !== 'simple') opts.AZURE_USE_OUTPUT_FORMAT_DETAILED = 1;
if (this.azureServiceEndpoint) opts.AZURE_SERVICE_ENDPOINT = this.azureServiceEndpoint;
await ep.set(opts)
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with azure'));
}
await this._transcribe(ep); await this._transcribe(ep);
} }
@@ -239,50 +248,61 @@ class TaskTranscribe extends Task {
}); });
} }
_onTranscription(cs, ep, channel, evt, fsEvent) { async _onTranscription(cs, ep, channel, evt, fsEvent) {
// make sure this is not a transcript from answering machine detection // make sure this is not a transcript from answering machine detection
const bugname = fsEvent.getHeader('media-bugname'); const bugname = fsEvent.getHeader('media-bugname');
if (bugname && this.bugname !== bugname) return; if (bugname && this.bugname !== bugname) return;
this.logger.debug({evt, channel}, 'TaskTranscribe:_onTranscription'); if (this.vendor === 'ibm') {
if ('aws' === this.vendor && Array.isArray(evt) && evt.length > 0) evt = evt[0]; if (evt?.state === 'listening') return;
if ('microsoft' === this.vendor) { }
const nbest = evt.NBest; this.logger.debug({evt}, 'TaskTranscribe:_onTranscription - before normalization');
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 = { evt = this.normalizeTranscription(evt, this.vendor, channel, this.language);
is_final: evt.RecognitionStatus === 'Success',
channel, this.logger.debug({evt}, 'TaskTranscribe:_onTranscription');
language_code,
alternatives if (evt.alternatives[0]?.transcript === '' && !cs.callGone && !this.killed) {
}; if (['microsoft', 'deepgram'].includes(this.vendor)) {
evt = newEvent; this.logger.info({evt}, 'TaskTranscribe:_onTranscription - got empty transcript, continue listening');
}
else {
this.logger.info({evt}, 'TaskTranscribe:_onTranscription - got empty transcript, listen again');
this._transcribe(ep);
}
return;
} }
if (evt.alternatives[0].transcript === '' && !cs.callGone && !this.killed) { if (this.vendor === 'soniox') {
this.logger.info({evt}, 'TaskGather:_onTranscription - got empty transcript, listen again'); /* compile transcripts into one */
return this._transcribe(ep); this._sonioxTranscripts.push(evt.vendor.finalWords);
if (evt.is_final) {
evt = this.compileSonioxTranscripts(this._sonioxTranscripts, 1, this.language);
this._sonioxTranscripts = [];
}
} }
evt.channel_tag = channel;
if (this.transcriptionHook) { if (this.transcriptionHook) {
const b3 = this.getTracingPropagation(); const b3 = this.getTracingPropagation();
const httpHeaders = b3 && {b3}; const httpHeaders = b3 && {b3};
this.cs.requestor.request('verb:hook', this.transcriptionHook, try {
Object.assign({speech: evt}, this.cs.callInfo), httpHeaders) const json = await this.cs.requestor.request('verb:hook', this.transcriptionHook, {
.catch((err) => this.logger.info(err, 'TranscribeTask:_onTranscription error')); ...this.cs.callInfo,
...httpHeaders,
speech: evt
});
this.logger.info({json}, 'sent transcriptionHook');
if (json && Array.isArray(json) && !this.parentTask) {
const makeTask = require('./make_task');
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
if (tasks && tasks.length > 0) {
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
this.cs.replaceApplication(tasks);
}
}
} catch (err) {
this.logger.info(err, 'TranscribeTask:_onTranscription error');
}
} }
if (this.parentTask) { if (this.parentTask) {
this.parentTask.emit('transcription', evt); this.parentTask.emit('transcription', evt);
@@ -310,6 +330,64 @@ class TaskTranscribe extends Task {
this._timer = null; this._timer = null;
} }
} }
_onDeepgramConnect(_cs, _ep) {
this.logger.debug('TaskTranscribe:_onDeepgramConnect');
}
_onDeepGramConnectFailure(cs, _ep, _channel, evt) {
const {reason} = evt;
const {writeAlerts, AlertType} = cs.srf.locals;
this.logger.info({evt}, 'TaskTranscribe:_onDeepgramConnectFailure');
writeAlerts({
account_sid: cs.accountSid,
alert_type: AlertType.STT_FAILURE,
message: `Failed connecting to Deepgram speech recognizer: ${reason}`,
vendor: 'deepgram',
}).catch((err) => this.logger.info({err}, 'Error generating alert for deepgram connection failure'));
this.notifyError(`Failed connecting to speech vendor deepgram: ${reason}`);
this.notifyTaskDone();
}
_onIbmConnect(_cs, _ep) {
this.logger.debug('TaskTranscribe:_onIbmConnect');
}
_onIbmConnectFailure(cs, _ep, _channel, evt) {
const {reason} = evt;
const {writeAlerts, AlertType} = cs.srf.locals;
this.logger.info({evt}, 'TaskTranscribe:_onIbmConnectFailure');
writeAlerts({
account_sid: cs.accountSid,
alert_type: AlertType.STT_FAILURE,
message: `Failed connecting to IBM watson speech recognizer: ${reason}`,
vendor: 'ibm',
}).catch((err) => this.logger.info({err}, 'Error generating alert for IBM connection failure'));
this.notifyError(`Failed connecting to speech vendor IBM: ${reason}`);
this.notifyTaskDone();
}
_onIbmError(cs, _ep, _channel, evt) {
this.logger.info({evt}, 'TaskTranscribe:_onIbmError');
}
_onJambonzError(cs, _ep, evt) {
this.logger.info({evt}, 'TaskTranscribe:_onJambonzError');
const {writeAlerts, AlertType} = cs.srf.locals;
if (this.vendor === 'nuance') {
const {code, error} = evt;
if (code === 404 && error === 'No speech') return this._resolve('timeout');
if (code === 413 && error === 'Too much speech') return this._resolve('timeout');
}
this.logger.info({evt}, 'TaskTranscribe:_onJambonzError');
writeAlerts({
account_sid: cs.accountSid,
alert_type: AlertType.STT_FAILURE,
message: `Custom speech vendor ${this.vendor} error: ${evt.error}`,
vendor: this.vendor,
}).catch((err) => this.logger.info({err}, 'Error generating alert for jambonz custom connection failure'));
this.notifyError({msg: 'ASR error', details:`Custom speech vendor ${this.vendor} error: ${evt.error}`});
}
} }
module.exports = TaskTranscribe; module.exports = TaskTranscribe;

View File

@@ -267,32 +267,51 @@ module.exports = (logger) => {
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription, onTranscription.bind(null, cs, ep, task)); ep.addCustomEventListener(AzureTranscriptionEvents.Transcription, onTranscription.bind(null, cs, ep, task));
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected, onNoSpeechDetected.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')); await ep.set(sttOpts).catch((err) => logger.info(err, 'Error setting channel variables'));
amd amd
.on(AmdEvents.NoSpeechDetected, (evt) => { .on(AmdEvents.NoSpeechDetected, (evt) => {
task.emit('amd', {type: AmdEvents.NoSpeechDetected, ...evt}); task.emit('amd', {type: AmdEvents.NoSpeechDetected, ...evt});
ep.stopTranscription({vendor, bugname}); try {
ep.connected && ep.stopTranscription({vendor, bugname});
} catch (err) {
logger.info({err}, 'Error stopping transcription');
}
}) })
.on(AmdEvents.HumanDetected, (evt) => { .on(AmdEvents.HumanDetected, (evt) => {
task.emit('amd', {type: AmdEvents.HumanDetected, ...evt}); task.emit('amd', {type: AmdEvents.HumanDetected, ...evt});
ep.stopTranscription({vendor, bugname}); try {
ep.connected && ep.stopTranscription({vendor, bugname});
} catch (err) {
logger.info({err}, 'Error stopping transcription');
}
}) })
.on(AmdEvents.MachineDetected, (evt) => { .on(AmdEvents.MachineDetected, (evt) => {
task.emit('amd', {type: AmdEvents.MachineDetected, ...evt}); task.emit('amd', {type: AmdEvents.MachineDetected, ...evt});
}) })
.on(AmdEvents.DecisionTimeout, (evt) => { .on(AmdEvents.DecisionTimeout, (evt) => {
task.emit('amd', {type: AmdEvents.DecisionTimeout, ...evt}); task.emit('amd', {type: AmdEvents.DecisionTimeout, ...evt});
ep.stopTranscription({vendor, bugname}); try {
ep.connected && ep.stopTranscription({vendor, bugname});
} catch (err) {
logger.info({err}, 'Error stopping transcription');
}
}) })
.on(AmdEvents.ToneTimeout, (evt) => { .on(AmdEvents.ToneTimeout, (evt) => {
//task.emit('amd', {type: AmdEvents.ToneTimeout, ...evt}); //task.emit('amd', {type: AmdEvents.ToneTimeout, ...evt});
ep.execute('avmd_stop').catch((err) => logger.info(err, 'Error stopping avmd')); 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, () => { .on(AmdEvents.MachineStoppedSpeaking, () => {
task.emit('amd', {type: AmdEvents.MachineStoppedSpeaking}); task.emit('amd', {type: AmdEvents.MachineStoppedSpeaking});
ep.stopTranscription({vendor, bugname}); try {
ep.connected && ep.stopTranscription({vendor, bugname});
} catch (err) {
logger.info({err}, 'Error stopping transcription');
}
}); });
/* start transcribing, and also listening for beep */ /* start transcribing, and also listening for beep */

View File

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

View File

@@ -29,6 +29,7 @@
"Tag": "tag", "Tag": "tag",
"Transcribe": "transcribe" "Transcribe": "transcribe"
}, },
"AllowedSipRecVerbs": ["config", "gather", "transcribe", "listen"],
"CallStatus": { "CallStatus": {
"Trying": "trying", "Trying": "trying",
"Ringing": "ringing", "Ringing": "ringing",
@@ -66,6 +67,35 @@
"MaxDurationExceeded": "google_transcribe::max_duration_exceeded", "MaxDurationExceeded": "google_transcribe::max_duration_exceeded",
"VadDetected": "google_transcribe::vad_detected" "VadDetected": "google_transcribe::vad_detected"
}, },
"NuanceTranscriptionEvents": {
"Transcription": "nuance_transcribe::transcription",
"StartOfSpeech": "nuance_transcribe::start_of_speech",
"TranscriptionComplete": "nuance_transcribe::end_of_transcription",
"Error": "nuance_transcribe::error",
"VadDetected": "nuance_transcribe::vad_detected"
},
"NvidiaTranscriptionEvents": {
"Transcription": "nvidia_transcribe::transcription",
"StartOfSpeech": "nvidia_transcribe::start_of_speech",
"TranscriptionComplete": "nvidia_transcribe::end_of_transcription",
"Error": "nvidia_transcribe::error",
"VadDetected": "nvidia_transcribe::vad_detected"
},
"DeepgramTranscriptionEvents": {
"Transcription": "deepgram_transcribe::transcription",
"ConnectFailure": "deepgram_transcribe::connect_failed",
"Connect": "deepgram_transcribe::connect"
},
"SonioxTranscriptionEvents": {
"Transcription": "soniox_transcribe::transcription",
"Error": "soniox_transcribe::error"
},
"IbmTranscriptionEvents": {
"Transcription": "ibm_transcribe::transcription",
"ConnectFailure": "ibm_transcribe::connect_failed",
"Connect": "ibm_transcribe::connect",
"Error": "ibm_transcribe::error"
},
"AwsTranscriptionEvents": { "AwsTranscriptionEvents": {
"Transcription": "aws_transcribe::transcription", "Transcription": "aws_transcribe::transcription",
"EndOfTranscript": "aws_transcribe::end_of_transcript", "EndOfTranscript": "aws_transcribe::end_of_transcript",
@@ -80,6 +110,12 @@
"NoSpeechDetected": "azure_transcribe::no_speech_detected", "NoSpeechDetected": "azure_transcribe::no_speech_detected",
"VadDetected": "azure_transcribe::vad_detected" "VadDetected": "azure_transcribe::vad_detected"
}, },
"JambonzTranscriptionEvents": {
"Transcription": "jambonz_transcribe::transcription",
"ConnectFailure": "jambonz_transcribe::connect_failed",
"Connect": "jambonz_transcribe::connect",
"Error": "jambonz_transcribe::error"
},
"ListenEvents": { "ListenEvents": {
"Connect": "mod_audio_fork::connect", "Connect": "mod_audio_fork::connect",
"ConnectFailure": "mod_audio_fork::connect_failed", "ConnectFailure": "mod_audio_fork::connect_failed",
@@ -121,6 +157,7 @@
"queue:status", "queue:status",
"dial:confirm", "dial:confirm",
"verb:hook", "verb:hook",
"verb:status",
"jambonz:error" "jambonz:error"
], ],
"RecordState": { "RecordState": {

View File

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

View File

@@ -23,22 +23,57 @@ AND vc.name = ?`;
const speechMapper = (cred) => { const speechMapper = (cred) => {
const {credential, ...obj} = cred; const {credential, ...obj} = cred;
if ('google' === obj.vendor) { try {
obj.service_key = decrypt(credential); if ('google' === obj.vendor) {
} obj.service_key = decrypt(credential);
else if ('aws' === obj.vendor) { }
const o = JSON.parse(decrypt(credential)); else if ('aws' === obj.vendor) {
obj.access_key_id = o.access_key_id; const o = JSON.parse(decrypt(credential));
obj.secret_access_key = o.secret_access_key; obj.access_key_id = o.access_key_id;
} obj.secret_access_key = o.secret_access_key;
else if ('microsoft' === obj.vendor) { obj.aws_region = o.aws_region;
const o = JSON.parse(decrypt(credential)); }
obj.api_key = o.api_key; else if ('microsoft' === obj.vendor) {
obj.region = o.region; const o = JSON.parse(decrypt(credential));
} obj.api_key = o.api_key;
else if ('wellsaid' === obj.vendor) { obj.region = o.region;
const o = JSON.parse(decrypt(credential)); obj.use_custom_stt = o.use_custom_stt;
obj.api_key = o.api_key; obj.custom_stt_endpoint = o.custom_stt_endpoint;
obj.use_custom_tts = o.use_custom_tts;
obj.custom_tts_endpoint = o.custom_tts_endpoint;
}
else if ('wellsaid' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
}
else if ('nuance' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.client_id = o.client_id;
obj.secret = o.secret;
}
else if ('ibm' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.tts_api_key = o.tts_api_key;
obj.tts_region = o.tts_region;
obj.stt_api_key = o.stt_api_key;
obj.stt_region = o.stt_region;
}
else if ('deepgram' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
}
else if ('soniox' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
}
else if (obj.vendor.startsWith('custom:')) {
const o = JSON.parse(decrypt(credential));
obj.auth_token = o.auth_token;
obj.custom_stt_url = o.custom_stt_url;
obj.custom_tts_url = o.custom_tts_url;
}
} catch (err) {
console.log(err);
} }
return obj; return obj;
}; };
@@ -54,32 +89,13 @@ module.exports = (logger, srf) => {
const [r2] = await pp.query(sqlSpeechCredentials, account_sid); const [r2] = await pp.query(sqlSpeechCredentials, account_sid);
const speech = r2.map(speechMapper); const speech = r2.map(speechMapper);
/* search at the service provider level if we don't find it at the account level */ /* add service provider creds unless we have that vendor at the account level */
const haveGoogle = speech.find((s) => s.vendor === 'google'); const [r3] = await pp.query(sqlSpeechCredentialsForSP, account_sid);
const haveAws = speech.find((s) => s.vendor === 'aws'); r3.forEach((s) => {
const haveMicrosoft = speech.find((s) => s.vendor === 'microsoft'); if (!speech.find((s2) => s2.vendor === s.vendor)) {
const haveWellsaid = speech.find((s) => s.vendor === 'wellsaid'); speech.push(speechMapper(s));
if (!haveGoogle || !haveAws || !haveMicrosoft) {
const [r3] = await pp.query(sqlSpeechCredentialsForSP, account_sid);
if (r3.length) {
if (!haveGoogle) {
const google = r3.find((s) => s.vendor === 'google');
if (google) speech.push(speechMapper(google));
}
if (!haveAws) {
const aws = r3.find((s) => s.vendor === 'aws');
if (aws) speech.push(speechMapper(aws));
}
if (!haveMicrosoft) {
const ms = r3.find((s) => s.vendor === 'microsoft');
if (ms) speech.push(speechMapper(ms));
}
if (!haveWellsaid) {
const wellsaid = r3.find((s) => s.vendor === 'wellsaid');
if (wellsaid) speech.push(speechMapper(wellsaid));
}
} }
} });
return { return {
...r[0], ...r[0],
@@ -88,6 +104,7 @@ module.exports = (logger, srf) => {
}; };
const updateSpeechCredentialLastUsed = async(speech_credential_sid) => { const updateSpeechCredentialLastUsed = async(speech_credential_sid) => {
if (!speech_credential_sid) return;
const pp = pool.promise(); const pp = pool.promise();
const sql = 'UPDATE speech_credentials SET last_used = NOW() WHERE speech_credential_sid = ?'; const sql = 'UPDATE speech_credentials SET last_used = NOW() WHERE speech_credential_sid = ?';
try { try {

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

@@ -28,9 +28,12 @@ class HttpRequestor extends BaseRequestor {
assert(['GET', 'POST'].includes(this.method)); assert(['GET', 'POST'].includes(this.method));
const u = this._parsedUrl = parseUrl(this.url); const u = this._parsedUrl = parseUrl(this.url);
this._baseUrl = `${u.protocol}://${u.resource}`; if (u.port) this._baseUrl = `${u.protocol}://${u.resource}:${u.port}`;
this._resource = u.resource; else this._baseUrl = `${u.protocol}://${u.resource}`;
this._protocol = u.protocol; 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); this._usePools = process.env.HTTP_POOL && parseInt(process.env.HTTP_POOL);
if (this._usePools) { if (this._usePools) {
@@ -48,7 +51,10 @@ class HttpRequestor extends BaseRequestor {
this.logger.debug(`HttpRequestor:created pool for ${this._baseUrl}`); this.logger.debug(`HttpRequestor:created pool for ${this._baseUrl}`);
} }
} }
else this.client = new Client(`${u.protocol}://${u.resource}`); 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() { get baseUrl() {
@@ -83,26 +89,40 @@ class HttpRequestor extends BaseRequestor {
assert.ok(url, 'HttpRequestor:request url was not provided'); 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}`); 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(); const startAt = process.hrtime();
/* if we have an absolute url, and it is ws then do a websocket connection */
if (this._isAbsoluteUrl(url) && url.startsWith('ws')) {
const WsRequestor = require('./ws-requestor');
this.logger.debug({hook}, 'HttpRequestor: switching to websocket connection');
const h = typeof hook === 'object' ? hook : {url: hook};
const requestor = new WsRequestor(this.logger, this.account_sid, h, this.secret);
if (type === 'session:redirect') {
this.close();
this.emit('handover', requestor);
}
return requestor.request('session:new', hook, params, httpHeaders);
}
let newClient; let newClient;
try { try {
let client, path; let client, path, query;
if (this._isRelativeUrl(url)) { if (this._isRelativeUrl(url)) {
client = this.client; client = this.client;
path = url; path = url;
} }
else { else {
const u = parseUrl(url); const u = parseUrl(url);
if (u.resource === this._resource && u.protocol === this._protocol) { if (u.resource === this._resource && u.port === this._port && u.protocol === this._protocol) {
client = this.client; client = this.client;
path = u.pathname; path = u.pathname;
query = u.query;
} }
else { else {
client = newClient = new Client(`${u.protocol}://${u.resource}`); 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; path = u.pathname;
query = u.query;
} }
} }
const sigHeader = this._generateSigHeader(payload, this.secret); const sigHeader = this._generateSigHeader(payload, this.secret);
@@ -116,6 +136,7 @@ class HttpRequestor extends BaseRequestor {
this.logger.debug({url, absUrl, hdrs}, 'send webhook'); this.logger.debug({url, absUrl, hdrs}, 'send webhook');
const {statusCode, headers, body} = await client.request({ const {statusCode, headers, body} = await client.request({
path, path,
query,
method, method,
headers: hdrs, headers: hdrs,
...('POST' === method && {body: JSON.stringify(payload)}), ...('POST' === method && {body: JSON.stringify(payload)}),

View File

@@ -138,7 +138,6 @@ function installSrfLocals(srf, logger) {
retrieveCall, retrieveCall,
listCalls, listCalls,
deleteCall, deleteCall,
synthAudio,
createHash, createHash,
retrieveHash, retrieveHash,
deleteKey, deleteKey,
@@ -151,12 +150,20 @@ function installSrfLocals(srf, logger) {
pushBack, pushBack,
popFront, popFront,
removeFromList, removeFromList,
getListPosition,
lengthOfList, lengthOfList,
getListPosition
} = require('@jambonz/realtimedb-helpers')({ } = require('@jambonz/realtimedb-helpers')({
host: process.env.JAMBONES_REDIS_HOST, host: process.env.JAMBONES_REDIS_HOST,
port: process.env.JAMBONES_REDIS_PORT || 6379 port: process.env.JAMBONES_REDIS_PORT || 6379
}, logger, tracer); }, logger, tracer);
const {
synthAudio,
getNuanceAccessToken,
getIbmAccessToken,
} = require('@jambonz/speech-utils')({
host: process.env.JAMBONES_REDIS_HOST,
port: process.env.JAMBONES_REDIS_PORT || 6379
}, logger, tracer);
const { const {
writeAlerts, writeAlerts,
AlertType AlertType
@@ -204,7 +211,9 @@ function installSrfLocals(srf, logger) {
popFront, popFront,
removeFromList, removeFromList,
lengthOfList, lengthOfList,
getListPosition getListPosition,
getNuanceAccessToken,
getIbmAccessToken
}, },
parentLogger: logger, parentLogger: logger,
getSBC, getSBC,

View File

@@ -1,31 +0,0 @@
function normalizeJambones(logger, obj) {
if (!Array.isArray(obj)) throw new Error('malformed jambonz payload: must be array');
const document = [];
for (const tdata of obj) {
if (typeof tdata !== 'object') throw new Error('malformed jambonz payload: must be array of objects');
if ('verb' in tdata) {
// {verb: 'say', text: 'foo..bar'..}
const name = tdata.verb;
const o = {};
Object.keys(tdata)
.filter((k) => k !== 'verb')
.forEach((k) => o[k] = tdata[k]);
const o2 = {};
o2[name] = o;
document.push(o2);
}
else if (Object.keys(tdata).length === 1) {
// {'say': {..}}
document.push(tdata);
}
else {
logger.info(tdata, 'malformed jambonz payload: missing verb property');
throw new Error('malformed jambonz payload: missing verb property');
}
}
logger.debug({document}, `normalizeJambones: returning document with ${document.length} tasks`);
return document;
}
module.exports = normalizeJambones;

View File

@@ -4,7 +4,7 @@ const SipError = require('drachtio-srf').SipError;
const {TaskPreconditions, CallDirection} = require('../utils/constants'); const {TaskPreconditions, CallDirection} = require('../utils/constants');
const CallInfo = require('../session/call-info'); const CallInfo = require('../session/call-info');
const assert = require('assert'); const assert = require('assert');
const normalizeJambones = require('../utils/normalize-jambones'); const { normalizeJambones } = require('@jambonz/verb-specifications');
const makeTask = require('../tasks/make_task'); const makeTask = require('../tasks/make_task');
const ConfirmCallSession = require('../session/confirm-call-session'); const ConfirmCallSession = require('../session/confirm-call-session');
const AdultingCallSession = require('../session/adulting-call-session'); const AdultingCallSession = require('../session/adulting-call-session');
@@ -12,7 +12,7 @@ const deepcopy = require('deepcopy');
const moment = require('moment'); const moment = require('moment');
const stripCodecs = require('./strip-ancillary-codecs'); const stripCodecs = require('./strip-ancillary-codecs');
const RootSpan = require('./call-tracer'); const RootSpan = require('./call-tracer');
const { v4: uuidv4 } = require('uuid'); const uuidv4 = require('uuid-random');
class SingleDialer extends Emitter { class SingleDialer extends Emitter {
constructor({logger, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan}) { constructor({logger, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan}) {
@@ -21,6 +21,7 @@ class SingleDialer extends Emitter {
this.logger = logger; this.logger = logger;
this.target = target; this.target = target;
this.from = target.from || {};
this.sbcAddress = sbcAddress; this.sbcAddress = sbcAddress;
this.opts = opts; this.opts = opts;
this.application = application; this.application = application;
@@ -66,8 +67,11 @@ class SingleDialer extends Emitter {
opts.headers = { opts.headers = {
...opts.headers, ...opts.headers,
...(this.target.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-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) { if (srf.locals.fsUUID) {
opts.headers = { opts.headers = {
@@ -408,7 +412,7 @@ class SingleDialer extends Emitter {
this.callInfo.updateCallStatus(callStatus, sipStatus, sipReason); this.callInfo.updateCallStatus(callStatus, sipStatus, sipReason);
if (typeof duration === 'number') this.callInfo.duration = duration; if (typeof duration === 'number') this.callInfo.duration = duration;
try { 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) { } catch (err) {
this.logger.info(err, `SingleDialer:_notifyCallStatusChange error sending ${callStatus} ${sipStatus}`); 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 assert = require('assert');
const snakeCaseKeys = require('./snakecase-keys');
const crypto = require('crypto');
const timeSeries = require('@jambonz/time-series'); const timeSeries = require('@jambonz/time-series');
let alerter ; 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) { function isAbsoluteUrl(u) {
return typeof u === 'string' && return typeof u === 'string' &&
u.startsWith('https://') || u.startsWith('http://'); u.startsWith('https://') || u.startsWith('http://');
@@ -49,14 +14,6 @@ class Requestor {
this.logger = logger; this.logger = logger;
this.url = hook.url; this.url = hook.url;
this.method = hook.method || 'POST'; this.method = hook.method || 'POST';
this.authHeader = basicAuth(hook.username, hook.password);
const u = parseUrl(this.url);
const myPort = u.port ? `:${u.port}` : '';
const baseUrl = this._baseUrl = `${u.protocol}://${u.resource}${myPort}`;
this.get = bent(baseUrl, 'GET', 'buffer', 200, 201);
this.post = bent(baseUrl, 'POST', 'buffer', 200, 201);
this.username = hook.username; this.username = hook.username;
this.password = hook.password; this.password = hook.password;
@@ -78,72 +35,15 @@ class Requestor {
} }
} }
get baseUrl() { get Alerter() {
return this._baseUrl; if (!alerter) {
} alerter = timeSeries(this.logger, {
host: process.env.JAMBONES_TIME_SERIES_HOST,
/** commitSize: 50,
* Make an HTTP request. commitInterval: 'test' === process.env.NODE_ENV ? 7 : 20
* 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()}'`);
}
} }
return alerter;
} }
} }

View File

@@ -1,5 +1,5 @@
const assert = require('assert'); const assert = require('assert');
const { v4: uuidv4 } = require('uuid'); const uuidv4 = require('uuid-random');
const {LifeCycleEvents, FS_UUID_SET_NAME} = require('./constants'); const {LifeCycleEvents, FS_UUID_SET_NAME} = require('./constants');
const Emitter = require('events'); const Emitter = require('events');
const debug = require('debug')('jambonz:feature-server'); const debug = require('debug')('jambonz:feature-server');
@@ -75,6 +75,10 @@ module.exports = (logger) => {
} }
})(); })();
} }
else if (process.env.K8S) {
lifecycleEmitter.scaleIn = () => process.exit(0);
}
async function pingProxies(srf) { async function pingProxies(srf) {
if (process.env.NODE_ENV === 'test') return; if (process.env.NODE_ENV === 'test') return;
@@ -104,8 +108,14 @@ module.exports = (logger) => {
const {srf} = require('../..'); const {srf} = require('../..');
const {addToSet} = srf.locals.dbHelpers; const {addToSet} = srf.locals.dbHelpers;
const uuid = srf.locals.fsUUID = uuidv4(); 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 { 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

@@ -1,33 +1,697 @@
module.exports = (logger) => { const {
const normalizeTranscription = (evt, vendor, channel) => { TaskName,
if ('aws' === vendor && Array.isArray(evt) && evt.length > 0) evt = evt[0]; AzureTranscriptionEvents,
if ('microsoft' === vendor) { GoogleTranscriptionEvents,
const nbest = evt.NBest; AwsTranscriptionEvents,
const language_code = evt.PrimaryLanguage?.Language || this.language; NuanceTranscriptionEvents,
const alternatives = nbest ? nbest.map((n) => { DeepgramTranscriptionEvents,
return { SonioxTranscriptionEvents,
confidence: n.Confidence, NvidiaTranscriptionEvents,
transcript: n.Display JambonzTranscriptionEvents
}; } = require('./constants');
}) :
[
{
transcript: evt.DisplayText || evt.Text
}
];
const newEvent = { const stickyVars = {
is_final: evt.RecognitionStatus === 'Success', google: [
channel, 'GOOGLE_SPEECH_HINTS',
language_code, 'GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL',
alternatives 'GOOGLE_SPEECH_PROFANITY_FILTER',
}; 'GOOGLE_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION',
evt = newEvent; 'GOOGLE_SPEECH_ENABLE_WORD_TIME_OFFSETS',
'GOOGLE_SPEECH_SINGLE_UTTERANCE',
'GOOGLE_SPEECH_SPEAKER_DIARIZATION',
'GOOGLE_SPEECH_USE_ENHANCED',
'GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES',
'GOOGLE_SPEECH_METADATA_INTERACTION_TYPE',
'GOOGLE_SPEECH_METADATA_INDUSTRY_NAICS_CODE'
],
microsoft: [
'AZURE_SPEECH_HINTS',
'AZURE_SERVICE_ENDPOINT_ID',
'AZURE_REQUEST_SNR',
'AZURE_PROFANITY_OPTION',
'AZURE_SERVICE_ENDPOINT',
'AZURE_INITIAL_SPEECH_TIMEOUT_MS',
'AZURE_USE_OUTPUT_FORMAT_DETAILED',
],
deepgram: [
'DEEPGRAM_SPEECH_KEYWORDS',
'DEEPGRAM_API_KEY',
'DEEPGRAM_SPEECH_TIER',
'DEEPGRAM_SPEECH_MODEL',
'DEEPGRAM_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION',
'DEEPGRAM_SPEECH_PROFANITY_FILTER',
'DEEPGRAM_SPEECH_REDACT',
'DEEPGRAM_SPEECH_DIARIZE',
'DEEPGRAM_SPEECH_NER',
'DEEPGRAM_SPEECH_ALTERNATIVES',
'DEEPGRAM_SPEECH_NUMERALS',
'DEEPGRAM_SPEECH_SEARCH',
'DEEPGRAM_SPEECH_REPLACE',
'DEEPGRAM_SPEECH_ENDPOINTING',
'DEEPGRAM_SPEECH_VAD_TURNOFF',
'DEEPGRAM_SPEECH_TAG'
],
aws: [
'AWS_VOCABULARY_NAME',
'AWS_VOCABULARY_FILTER_METHOD',
'AWS_VOCABULARY_FILTER_NAME'
],
nuance: [
'NUANCE_ACCESS_TOKEN',
'NUANCE_KRYPTON_ENDPOINT',
'NUANCE_TOPIC',
'NUANCE_UTTERANCE_DETECTION_MODE',
'NUANCE_FILTER_PROFANITY',
'NUANCE_INCLUDE_TOKENIZATION',
'NUANCE_DISCARD_SPEAKER_ADAPTATION',
'NUANCE_SUPPRESS_CALL_RECORDING',
'NUANCE_MASK_LOAD_FAILURES',
'NUANCE_SUPPRESS_INITIAL_CAPITALIZATION',
'NUANCE_ALLOW_ZERO_BASE_LM_WEIGHT',
'NUANCE_FILTER_WAKEUP_WORD',
'NUANCE_NO_INPUT_TIMEOUT_MS',
'NUANCE_RECOGNITION_TIMEOUT_MS',
'NUANCE_UTTERANCE_END_SILENCE_MS',
'NUANCE_MAX_HYPOTHESES',
'NUANCE_SPEECH_DOMAIN',
'NUANCE_FORMATTING',
'NUANCE_RESOURCES'
],
ibm: [
'IBM_ACCESS_TOKEN',
'IBM_SPEECH_REGION',
'IBM_SPEECH_INSTANCE_ID',
'IBM_SPEECH_MODEL',
'IBM_SPEECH_LANGUAGE_CUSTOMIZATION_ID',
'IBM_SPEECH_ACOUSTIC_CUSTOMIZATION_ID',
'IBM_SPEECH_BASE_MODEL_VERSION',
'IBM_SPEECH_WATSON_METADATA',
'IBM_SPEECH_WATSON_LEARNING_OPT_OUT'
],
nvidia: [
'NVIDIA_HINTS'
],
soniox: [
'SONIOX_PROFANITY_FILTER',
'SONIOX_MODEL'
]
};
const compileSonioxTranscripts = (finalWordChunks, channel, language) => {
const words = finalWordChunks.flat();
const transcript = words.reduce((acc, word) => {
if (word.text === '<end>') return acc;
if ([',', '.', '?', '!'].includes(word.text)) return `${acc}${word.text}`;
return `${acc} ${word.text}`;
}, '').trim();
const realWords = words.filter((word) => ![',.!?;'].includes(word.text) && word.text !== '<end>');
const confidence = realWords.reduce((acc, word) => acc + word.confidence, 0) / realWords.length;
const alternatives = [{transcript, confidence}];
return {
language_code: language,
channel_tag: channel,
is_final: true,
alternatives,
vendor: {
name: 'soniox',
evt: words
}
};
};
const normalizeSoniox = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
/* an <end> token indicates the end of an utterance */
const endTokenPos = evt.words.map((w) => w.text).indexOf('<end>');
const endpointReached = endTokenPos !== -1;
const words = endpointReached ? evt.words.slice(0, endTokenPos) : evt.words;
/* note: we can safely ignore words after the <end> token as they will be returned again */
const finalWords = words.filter((word) => word.is_final);
const nonFinalWords = words.filter((word) => !word.is_final);
const is_final = endpointReached && finalWords.length > 0;
const transcript = words.reduce((acc, word) => {
if ([',', '.', '?', '!'].includes(word.text)) return `${acc}${word.text}`;
else return `${acc} ${word.text}`;
}, '').trim();
const realWords = words.filter((word) => ![',.!?;'].includes(word.text) && word.text !== '<end>');
const confidence = realWords.reduce((acc, word) => acc + word.confidence, 0) / realWords.length;
const alternatives = [{transcript, confidence}];
return {
language_code: language,
channel_tag: channel,
is_final,
alternatives,
vendor: {
name: 'soniox',
endpointReached,
evt: copy,
finalWords,
nonFinalWords
}
};
};
const normalizeDeepgram = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
const alternatives = (evt.channel?.alternatives || [])
.map((alt) => ({
confidence: alt.confidence,
transcript: alt.transcript,
}));
return {
language_code: language,
channel_tag: channel,
is_final: evt.is_final,
alternatives: [alternatives[0]],
vendor: {
name: 'deepgram',
evt: copy
}
};
};
const normalizeNvidia = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
const alternatives = (evt.alternatives || [])
.map((alt) => ({
confidence: alt.confidence,
transcript: alt.transcript,
}));
return {
language_code: language,
channel_tag: channel,
is_final: evt.is_final,
alternatives,
vendor: {
name: 'nvidia',
evt: copy
}
};
};
const normalizeIbm = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
//const idx = evt.result_index;
const result = evt.results[0];
return {
language_code: language,
channel_tag: channel,
is_final: result.final,
alternatives: result.alternatives,
vendor: {
name: 'ibm',
evt: copy
}
};
};
const normalizeGoogle = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
return {
language_code: language,
channel_tag: channel,
is_final: evt.is_final,
alternatives: [evt.alternatives[0]],
vendor: {
name: 'google',
evt: copy
}
};
};
const normalizeCustom = (evt, channel, language) => {
return {
language_code: language,
channel_tag: channel,
is_final: evt.is_final,
alternatives: [evt.alternatives[0]]
};
};
const normalizeNuance = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
return {
language_code: language,
channel_tag: channel,
is_final: evt.is_final,
alternatives: [evt.alternatives[0]],
vendor: {
name: 'nuance',
evt: copy
}
};
};
const normalizeMicrosoft = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
const nbest = evt.NBest;
const language_code = evt.PrimaryLanguage?.Language || language;
const alternatives = nbest ? nbest.map((n) => {
return {
confidence: n.Confidence,
transcript: n.Display
};
}) :
[
{
transcript: evt.DisplayText || evt.Text
}
];
return {
language_code,
channel_tag: channel,
is_final: evt.RecognitionStatus === 'Success',
alternatives: [alternatives[0]],
vendor: {
name: 'microsoft',
evt: copy
}
};
};
const normalizeAws = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
return {
language_code: language,
channel_tag: channel,
is_final: evt[0].is_final,
alternatives: evt[0].alternatives,
vendor: {
name: 'aws',
evt: copy
}
};
};
module.exports = (logger) => {
const normalizeTranscription = (evt, vendor, channel, language) => {
//logger.debug({ evt, vendor, channel, language }, 'normalizeTranscription');
switch (vendor) {
case 'deepgram':
return normalizeDeepgram(evt, channel, language);
case 'microsoft':
return normalizeMicrosoft(evt, channel, language);
case 'google':
return normalizeGoogle(evt, channel, language);
case 'aws':
return normalizeAws(evt, channel, language);
case 'nuance':
return normalizeNuance(evt, channel, language);
case 'ibm':
return normalizeIbm(evt, channel, language);
case 'nvidia':
return normalizeNvidia(evt, channel, language);
case 'soniox':
return normalizeSoniox(evt, channel, language);
default:
if (vendor.startsWith('custom:')) {
return normalizeCustom(evt, channel, language);
}
logger.error(`Unknown vendor ${vendor}`);
return evt;
} }
evt.channel_tag = channel;
//logger.debug({evt}, 'normalized transcription');
return evt;
}; };
return {normalizeTranscription}; const setChannelVarsForStt = (task, sttCredentials, rOpts = {}) => {
let opts = {};
const {enable, voiceMs = 0, mode = -1} = rOpts.vad || {};
const vad = {enable, voiceMs, mode};
const vendor = rOpts.vendor;
/* voice activity detection works across vendors */
opts = {
...opts,
...(vad.enable && {START_RECOGNIZING_ON_VAD: 1}),
...(vad.enable && vad.voiceMs && {RECOGNIZER_VAD_VOICE_MS: vad.voiceMs}),
...(vad.enable && typeof vad.mode === 'number' && {RECOGNIZER_VAD_MODE: vad.mode}),
};
if ('google' === vendor) {
opts = {
...opts,
...(sttCredentials &&
{GOOGLE_APPLICATION_CREDENTIALS: JSON.stringify(sttCredentials.credentials)}),
...(rOpts.enhancedModel &&
{GOOGLE_SPEECH_USE_ENHANCED: 1}),
...(rOpts.separateRecognitionPerChannel &&
{GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL: 1}),
...(rOpts.profanityFilter &&
{GOOGLE_SPEECH_PROFANITY_FILTER: 1}),
...(rOpts.punctuation &&
{GOOGLE_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION: 1}),
...(rOpts.words &&
{GOOGLE_SPEECH_ENABLE_WORD_TIME_OFFSETS: 1}),
...((rOpts.singleUtterance || task.name === TaskName.Gather) &&
{GOOGLE_SPEECH_SINGLE_UTTERANCE: 1}),
...(rOpts.diarization &&
{GOOGLE_SPEECH_SPEAKER_DIARIZATION: 1}),
...(rOpts.diarization && rOpts.diarizationMinSpeakers > 0 &&
{GOOGLE_SPEECH_SPEAKER_DIARIZATION_MIN_SPEAKER_COUNT: rOpts.diarizationMinSpeakers}),
...(rOpts.diarization && rOpts.diarizationMaxSpeakers > 0 &&
{GOOGLE_SPEECH_SPEAKER_DIARIZATION_MAX_SPEAKER_COUNT: rOpts.diarizationMaxSpeakers}),
...(rOpts.enhancedModel === false &&
{GOOGLE_SPEECH_USE_ENHANCED: 0}),
...(rOpts.separateRecognitionPerChannel === false &&
{GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL: 0}),
...(rOpts.profanityFilter === false &&
{GOOGLE_SPEECH_PROFANITY_FILTER: 0}),
...(rOpts.punctuation === false &&
{GOOGLE_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION: 0}),
...(rOpts.words == false &&
{GOOGLE_SPEECH_ENABLE_WORD_TIME_OFFSETS: 0}),
...((rOpts.singleUtterance === false || task.name === TaskName.Transcribe) &&
{GOOGLE_SPEECH_SINGLE_UTTERANCE: 0}),
...(rOpts.diarization === false &&
{GOOGLE_SPEECH_SPEAKER_DIARIZATION: 0}),
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'string' &&
{GOOGLE_SPEECH_HINTS: rOpts.hints.join(',')}),
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'object' &&
{GOOGLE_SPEECH_HINTS: JSON.stringify(rOpts.hints)}),
...(typeof rOpts.hintsBoost === 'number' &&
{GOOGLE_SPEECH_HINTS_BOOST: rOpts.hintsBoost}),
...(rOpts.altLanguages.length > 0 &&
{GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES: [...new Set(rOpts.altLanguages)].join(',')}),
...(rOpts.interactionType &&
{GOOGLE_SPEECH_METADATA_INTERACTION_TYPE: rOpts.interactionType}),
...{GOOGLE_SPEECH_MODEL: rOpts.model || (task.name === TaskName.Gather ? 'latest_short' : 'phone_call')},
...(rOpts.naicsCode > 0 &&
{GOOGLE_SPEECH_METADATA_INDUSTRY_NAICS_CODE: rOpts.naicsCode}),
};
}
else if (['aws', 'polly'].includes(vendor)) {
opts = {
...opts,
...(rOpts.vocabularyName && {AWS_VOCABULARY_NAME: rOpts.vocabularyName}),
...(rOpts.vocabularyFilterName && {AWS_VOCABULARY_FILTER_NAME: rOpts.vocabularyFilterName}),
...(rOpts.filterMethod && {AWS_VOCABULARY_FILTER_METHOD: rOpts.filterMethod}),
...(sttCredentials && {
AWS_ACCESS_KEY_ID: sttCredentials.accessKeyId,
AWS_SECRET_ACCESS_KEY: sttCredentials.secretAccessKey,
AWS_REGION: sttCredentials.region
}),
};
}
else if ('microsoft' === vendor) {
opts = {
...opts,
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'string' &&
{AZURE_SPEECH_HINTS: rOpts.hints.map((h) => h.trim()).join(',')}),
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'object' &&
{AZURE_SPEECH_HINTS: rOpts.hints.map((h) => h.phrase).join(',')}),
...(rOpts.altLanguages && rOpts.altLanguages.length > 0 &&
{AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES: [...new Set(rOpts.altLanguages)].join(',')}),
...(rOpts.requestSnr && {AZURE_REQUEST_SNR: 1}),
...(rOpts.profanityOption && {AZURE_PROFANITY_OPTION: rOpts.profanityOption}),
...(rOpts.azureServiceEndpoint && {AZURE_SERVICE_ENDPOINT: rOpts.azureServiceEndpoint}),
...(rOpts.initialSpeechTimeoutMs > 0 &&
{AZURE_INITIAL_SPEECH_TIMEOUT_MS: rOpts.initialSpeechTimeoutMs}),
...(rOpts.requestSnr && {AZURE_REQUEST_SNR: 1}),
...(rOpts.audioLogging && {AZURE_AUDIO_LOGGING: 1}),
...{AZURE_USE_OUTPUT_FORMAT_DETAILED: 1},
...(sttCredentials && {
AZURE_SUBSCRIPTION_KEY: sttCredentials.api_key,
AZURE_REGION: sttCredentials.region,
}),
...(sttCredentials.use_custom_stt && sttCredentials.custom_stt_endpoint &&
{AZURE_SERVICE_ENDPOINT_ID: sttCredentials.custom_stt_endpoint})
};
}
else if ('nuance' === vendor) {
/**
* Note: all nuance options are in recognizer.nuanceOptions, should migrate
* other vendor settings to similar nested structure
*/
const {nuanceOptions = {}} = rOpts;
opts = {
...opts,
...(sttCredentials.access_token) &&
{NUANCE_ACCESS_TOKEN: sttCredentials.access_token},
...(sttCredentials.krypton_endpoint) &&
{NUANCE_KRYPTON_ENDPOINT: sttCredentials.krypton_endpoint},
...(nuanceOptions.topic) &&
{NUANCE_TOPIC: nuanceOptions.topic},
...(nuanceOptions.utteranceDetectionMode) &&
{NUANCE_UTTERANCE_DETECTION_MODE: nuanceOptions.utteranceDetectionMode},
...(nuanceOptions.punctuation || rOpts.punctuation) && {NUANCE_PUNCTUATION: nuanceOptions.punctuation},
...(nuanceOptions.profanityFilter) &&
{NUANCE_FILTER_PROFANITY: nuanceOptions.profanityFilter},
...(nuanceOptions.includeTokenization) &&
{NUANCE_INCLUDE_TOKENIZATION: nuanceOptions.includeTokenization},
...(nuanceOptions.discardSpeakerAdaptation) &&
{NUANCE_DISCARD_SPEAKER_ADAPTATION: nuanceOptions.discardSpeakerAdaptation},
...(nuanceOptions.suppressCallRecording) &&
{NUANCE_SUPPRESS_CALL_RECORDING: nuanceOptions.suppressCallRecording},
...(nuanceOptions.maskLoadFailures) &&
{NUANCE_MASK_LOAD_FAILURES: nuanceOptions.maskLoadFailures},
...(nuanceOptions.suppressInitialCapitalization) &&
{NUANCE_SUPPRESS_INITIAL_CAPITALIZATION: nuanceOptions.suppressInitialCapitalization},
...(nuanceOptions.allowZeroBaseLmWeight)
&& {NUANCE_ALLOW_ZERO_BASE_LM_WEIGHT: nuanceOptions.allowZeroBaseLmWeight},
...(nuanceOptions.filterWakeupWord) &&
{NUANCE_FILTER_WAKEUP_WORD: nuanceOptions.filterWakeupWord},
...(nuanceOptions.resultType) &&
{NUANCE_RESULT_TYPE: nuanceOptions.resultType || rOpts.interim ? 'partial' : 'final'},
...(nuanceOptions.noInputTimeoutMs) &&
{NUANCE_NO_INPUT_TIMEOUT_MS: nuanceOptions.noInputTimeoutMs},
...(nuanceOptions.recognitionTimeoutMs) &&
{NUANCE_RECOGNITION_TIMEOUT_MS: nuanceOptions.recognitionTimeoutMs},
...(nuanceOptions.utteranceEndSilenceMs) &&
{NUANCE_UTTERANCE_END_SILENCE_MS: nuanceOptions.utteranceEndSilenceMs},
...(nuanceOptions.maxHypotheses) &&
{NUANCE_MAX_HYPOTHESES: nuanceOptions.maxHypotheses},
...(nuanceOptions.speechDomain) &&
{NUANCE_SPEECH_DOMAIN: nuanceOptions.speechDomain},
...(nuanceOptions.formatting) &&
{NUANCE_FORMATTING: nuanceOptions.formatting},
...(nuanceOptions.resources) &&
{NUANCE_RESOURCES: JSON.stringify(nuanceOptions.resources)},
};
}
else if ('deepgram' === vendor) {
const {deepgramOptions = {}} = rOpts;
opts = {
...opts,
...(sttCredentials.api_key) &&
{DEEPGRAM_API_KEY: sttCredentials.api_key},
...(deepgramOptions.tier) &&
{DEEPGRAM_SPEECH_TIER: deepgramOptions.tier},
...(deepgramOptions.model) &&
{DEEPGRAM_SPEECH_MODEL: deepgramOptions.model},
...(deepgramOptions.punctuate) &&
{DEEPGRAM_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION: 1},
...(deepgramOptions.profanityFilter) &&
{DEEPGRAM_SPEECH_PROFANITY_FILTER: 1},
...(deepgramOptions.redact) &&
{DEEPGRAM_SPEECH_REDACT: 1},
...(deepgramOptions.diarize) &&
{DEEPGRAM_SPEECH_DIARIZE: 1},
...(deepgramOptions.diarizeVersion) &&
{DEEPGRAM_SPEECH_DIARIZE_VERSION: deepgramOptions.diarizeVersion},
...(deepgramOptions.ner) &&
{DEEPGRAM_SPEECH_NER: 1},
...(deepgramOptions.alternatives) &&
{DEEPGRAM_SPEECH_ALTERNATIVES: deepgramOptions.alternatives},
...(deepgramOptions.numerals) &&
{DEEPGRAM_SPEECH_NUMERALS: deepgramOptions.numerals},
...(deepgramOptions.search) &&
{DEEPGRAM_SPEECH_SEARCH: deepgramOptions.search.join(',')},
...(deepgramOptions.replace) &&
{DEEPGRAM_SPEECH_REPLACE: deepgramOptions.replace.join(',')},
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'string' &&
{DEEPGRAM_SPEECH_KEYWORDS: rOpts.hints.map((h) => h.trim()).join(',')}),
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'object' &&
{DEEPGRAM_SPEECH_KEYWORDS: rOpts.hints.map((h) => h.phrase).join(',')}),
...(deepgramOptions.keywords) &&
{DEEPGRAM_SPEECH_KEYWORDS: deepgramOptions.keywords.join(',')},
...('endpointing' in deepgramOptions) &&
{DEEPGRAM_SPEECH_ENDPOINTING: deepgramOptions.endpointing},
...(deepgramOptions.vadTurnoff) &&
{DEEPGRAM_SPEECH_VAD_TURNOFF: deepgramOptions.vadTurnoff},
...(deepgramOptions.tag) &&
{DEEPGRAM_SPEECH_TAG: deepgramOptions.tag}
};
}
else if ('soniox' === vendor) {
const {sonioxOptions = {}} = rOpts;
const {storage = {}} = sonioxOptions;
opts = {
...opts,
...(sttCredentials.api_key) &&
{SONIOX_API_KEY: sttCredentials.api_key},
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'string' &&
{SONIOX_HINTS: rOpts.hints.join(',')}),
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'object' &&
{SONIOX_HINTS: JSON.stringify(rOpts.hints)}),
...(typeof rOpts.hintsBoost === 'number' &&
{SONIOX_HINTS_BOOST: rOpts.hintsBoost}),
...(sonioxOptions.model) &&
{SONIOX_MODEL: sonioxOptions.model},
...((sonioxOptions.profanityFilter || rOpts.profanityFilter) && {SONIOX_PROFANITY_FILTER: 1}),
...(storage?.id && {SONIOX_STORAGE_ID: storage.id}),
...(storage?.id && storage?.title && {SONIOX_STORAGE_TITLE: storage.title}),
...(storage?.id && storage?.disableStoreAudio && {SONIOX_STORAGE_DISABLE_AUDIO: 1}),
...(storage?.id && storage?.disableStoreTranscript && {SONIOX_STORAGE_DISABLE_TRANSCRIPT: 1}),
...(storage?.id && storage?.disableSearch && {SONIOX_STORAGE_DISABLE_SEARCH: 1})
};
}
else if ('ibm' === vendor) {
const {ibmOptions = {}} = rOpts;
opts = {
...opts,
...(sttCredentials.access_token) &&
{IBM_ACCESS_TOKEN: sttCredentials.access_token},
...(sttCredentials.stt_region) &&
{IBM_SPEECH_REGION: sttCredentials.stt_region},
...(sttCredentials.instance_id) &&
{IBM_SPEECH_INSTANCE_ID: sttCredentials.instance_id},
...(ibmOptions.model) &&
{IBM_SPEECH_MODEL: ibmOptions.model},
...(ibmOptions.language_customization_id) &&
{IBM_SPEECH_LANGUAGE_CUSTOMIZATION_ID: ibmOptions.language_customization_id},
...(ibmOptions.acoustic_customization_id) &&
{IBM_SPEECH_ACOUSTIC_CUSTOMIZATION_ID: ibmOptions.acoustic_customization_id},
...(ibmOptions.baseModelVersion) &&
{IBM_SPEECH_BASE_MODEL_VERSION: ibmOptions.baseModelVersion},
...(ibmOptions.watsonMetadata) &&
{IBM_SPEECH_WATSON_METADATA: ibmOptions.watsonMetadata},
...(ibmOptions.watsonLearningOptOut) &&
{IBM_SPEECH_WATSON_LEARNING_OPT_OUT: ibmOptions.watsonLearningOptOut}
};
}
else if ('nvidia' === vendor) {
const {nvidiaOptions = {}} = rOpts;
opts = {
...opts,
...((nvidiaOptions.profanityFilter || rOpts.profanityFilter) && {NVIDIA_PROFANITY_FILTER: 1}),
...(!(nvidiaOptions.profanityFilter || rOpts.profanityFilter) && {NVIDIA_PROFANITY_FILTER: 0}),
...((nvidiaOptions.punctuation || rOpts.punctuation) && {NVIDIA_PUNCTUATION: 1}),
...(!(nvidiaOptions.punctuation || rOpts.punctuation) && {NVIDIA_PUNCTUATION: 0}),
...((rOpts.words || nvidiaOptions.wordTimeOffsets) && {NVIDIA_WORD_TIME_OFFSETS: 1}),
...(!(rOpts.words || nvidiaOptions.wordTimeOffsets) && {NVIDIA_WORD_TIME_OFFSETS: 0}),
...(nvidiaOptions.maxAlternatives && {NVIDIA_MAX_ALTERNATIVES: nvidiaOptions.maxAlternatives}),
...(!nvidiaOptions.maxAlternatives && {NVIDIA_MAX_ALTERNATIVES: 1}),
...(rOpts.model && {NVIDIA_MODEL: rOpts.model}),
...(nvidiaOptions.rivaUri && {NVIDIA_RIVA_URI: nvidiaOptions.rivaUri}),
...(nvidiaOptions.verbatimTranscripts && {NVIDIA_VERBATIM_TRANSCRIPTS: 1}),
...(rOpts.diarization && {NVIDIA_SPEAKER_DIARIZATION: 1}),
...(rOpts.diarization && rOpts.diarizationMaxSpeakers > 0 &&
{NVIDIA_DIARIZATION_SPEAKER_COUNT: rOpts.diarizationMaxSpeakers}),
...(rOpts.separateRecognitionPerChannel && {NVIDIA_SEPARATE_RECOGNITION_PER_CHANNEL: 1}),
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'string' &&
{NVIDIA_HINTS: rOpts.hints.join(',')}),
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'object' &&
{NVIDIA_HINTS: JSON.stringify(rOpts.hints)}),
...(typeof rOpts.hintsBoost === 'number' &&
{NVIDIA_HINTS_BOOST: rOpts.hintsBoost}),
...(nvidiaOptions.customConfiguration &&
{NVIDIA_CUSTOM_CONFIGURATION: JSON.stringify(nvidiaOptions.customConfiguration)}),
};
}
else if (vendor.startsWith('custom:')) {
let {options = {}} = rOpts;
const {auth_token, custom_stt_url} = sttCredentials;
options = {
...options,
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'string' &&
{hints: rOpts.hints}),
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'object' &&
{hints: JSON.stringify(rOpts.hints)}),
...(typeof rOpts.hintsBoost === 'number' && {hintsBoost: rOpts.hintsBoost})
};
opts = {
...opts,
JAMBONZ_STT_API_KEY: auth_token,
JAMBONZ_STT_URL: custom_stt_url,
...(Object.keys(options).length > 0 && {JAMBONZ_STT_OPTIONS: JSON.stringify(options)}),
};
}
(stickyVars[vendor] || []).forEach((key) => {
if (!opts[key]) opts[key] = '';
});
return opts;
};
const removeSpeechListeners = (ep) => {
ep.removeCustomEventListener(GoogleTranscriptionEvents.Transcription);
ep.removeCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance);
ep.removeCustomEventListener(GoogleTranscriptionEvents.VadDetected);
ep.removeCustomEventListener(AwsTranscriptionEvents.Transcription);
ep.removeCustomEventListener(AwsTranscriptionEvents.VadDetected);
ep.removeCustomEventListener(AzureTranscriptionEvents.Transcription);
ep.removeCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected);
ep.removeCustomEventListener(AzureTranscriptionEvents.VadDetected);
ep.removeCustomEventListener(NuanceTranscriptionEvents.Transcription);
ep.removeCustomEventListener(NuanceTranscriptionEvents.TranscriptionComplete);
ep.removeCustomEventListener(NuanceTranscriptionEvents.StartOfSpeech);
ep.removeCustomEventListener(NuanceTranscriptionEvents.VadDetected);
ep.removeCustomEventListener(DeepgramTranscriptionEvents.Transcription);
ep.removeCustomEventListener(DeepgramTranscriptionEvents.Connect);
ep.removeCustomEventListener(DeepgramTranscriptionEvents.ConnectFailure);
ep.removeCustomEventListener(SonioxTranscriptionEvents.Transcription);
ep.removeCustomEventListener(NvidiaTranscriptionEvents.Transcription);
ep.removeCustomEventListener(NvidiaTranscriptionEvents.TranscriptionComplete);
ep.removeCustomEventListener(NvidiaTranscriptionEvents.StartOfSpeech);
ep.removeCustomEventListener(NvidiaTranscriptionEvents.VadDetected);
ep.removeCustomEventListener(JambonzTranscriptionEvents.Transcription);
ep.removeCustomEventListener(JambonzTranscriptionEvents.Connect);
ep.removeCustomEventListener(JambonzTranscriptionEvents.ConnectFailure);
ep.removeCustomEventListener(JambonzTranscriptionEvents.Error);
};
const setSpeechCredentialsAtRuntime = (recognizer) => {
if (!recognizer) return;
if (recognizer.vendor === 'nuance') {
const {clientId, secret, kryptonEndpoint} = recognizer.nuanceOptions || {};
if (clientId && secret) return {client_id: clientId, secret};
if (kryptonEndpoint) return {krypton_endpoint: kryptonEndpoint};
}
else if (recognizer.vendor === 'nvidia') {
const {rivaUri} = recognizer.nvidiaOptions || {};
if (rivaUri) return {riva_uri: rivaUri};
}
else if (recognizer.vendor === 'deepgram') {
const {apiKey} = recognizer.deepgramOptions || {};
if (apiKey) return {api_key: apiKey};
}
else if (recognizer.vendor === 'soniox') {
const {apiKey} = recognizer.sonioxOptions || {};
if (apiKey) return {api_key: apiKey};
}
else if (recognizer.vendor === 'ibm') {
const {ttsApiKey, ttsRegion, sttApiKey, sttRegion, instanceId} = recognizer.ibmOptions || {};
if (ttsApiKey || sttApiKey) return {
tts_api_key: ttsApiKey,
tts_region: ttsRegion,
stt_api_key: sttApiKey,
stt_region: sttRegion,
instance_id: instanceId
};
}
};
return {
normalizeTranscription,
setChannelVarsForStt,
removeSpeechListeners,
setSpeechCredentialsAtRuntime,
compileSonioxTranscripts
};
}; };

View File

@@ -4,7 +4,6 @@ const short = require('short-uuid');
const {HookMsgTypes} = require('./constants.json'); const {HookMsgTypes} = require('./constants.json');
const Websocket = require('ws'); const Websocket = require('ws');
const snakeCaseKeys = require('./snakecase-keys'); const snakeCaseKeys = require('./snakecase-keys');
const HttpRequestor = require('./http-requestor');
const MAX_RECONNECTS = 5; const MAX_RECONNECTS = 5;
const RESPONSE_TIMEOUT_MS = process.env.JAMBONES_WS_API_MSG_RESPONSE_TIMEOUT || 5000; const RESPONSE_TIMEOUT_MS = process.env.JAMBONES_WS_API_MSG_RESPONSE_TIMEOUT || 5000;
@@ -45,7 +44,7 @@ class WsRequestor extends BaseRequestor {
return; return;
} }
if (this.closedGracefully) { if (this.closedGracefully) {
this.logger.debug(`WsRequestor:request - discarding ${type} because we closed the socket`); this.logger.debug(`WsRequestor:request - discarding ${type} because socket was closed gracefully`);
return; return;
} }
@@ -53,8 +52,14 @@ class WsRequestor extends BaseRequestor {
/* if we have an absolute url, and it is http then do a standard webhook */ /* if we have an absolute url, and it is http then do a standard webhook */
if (this._isAbsoluteUrl(url) && url.startsWith('http')) { if (this._isAbsoluteUrl(url) && url.startsWith('http')) {
const HttpRequestor = require('./http-requestor');
this.logger.debug({hook}, 'WsRequestor: sending a webhook (HTTP)'); this.logger.debug({hook}, 'WsRequestor: sending a webhook (HTTP)');
const requestor = new HttpRequestor(this.logger, this.account_sid, hook, this.secret); const h = typeof hook === 'object' ? hook : {url: hook};
const requestor = new HttpRequestor(this.logger, this.account_sid, h, this.secret);
if (type === 'session:redirect') {
this.close();
this.emit('handover', requestor);
}
return requestor.request(type, hook, params, httpHeaders); return requestor.request(type, hook, params, httpHeaders);
} }
@@ -69,7 +74,7 @@ class WsRequestor extends BaseRequestor {
this.connectInProgress = true; this.connectInProgress = true;
this.logger.debug(`WsRequestor:request(${this.id}) - connecting since we do not have a connection`); this.logger.debug(`WsRequestor:request(${this.id}) - connecting since we do not have a connection`);
if (this.connections >= MAX_RECONNECTS) { if (this.connections >= MAX_RECONNECTS) {
throw new Error(`max attempts connecting to ${this.url}`); return Promise.reject(`max attempts connecting to ${this.url}`);
} }
try { try {
const startAt = process.hrtime(); const startAt = process.hrtime();
@@ -79,7 +84,7 @@ class WsRequestor extends BaseRequestor {
} catch (err) { } catch (err) {
this.logger.info({url, err}, 'WsRequestor:request - failed connecting'); this.logger.info({url, err}, 'WsRequestor:request - failed connecting');
this.connectInProgress = false; this.connectInProgress = false;
throw err; return Promise.reject(err);
} }
} }
assert(this.ws); assert(this.ws);
@@ -91,6 +96,9 @@ class WsRequestor extends BaseRequestor {
assert.ok(url, 'WsRequestor:request url was not provided'); assert.ok(url, 'WsRequestor:request url was not provided');
const msgid = short.generate(); const msgid = short.generate();
// save initial msgid in case we need to reconnect during initial session:new
if (type === 'session:new') this._initMsgId = msgid;
const b3 = httpHeaders?.b3 ? {b3: httpHeaders.b3} : {}; const b3 = httpHeaders?.b3 ? {b3: httpHeaders.b3} : {};
const obj = { const obj = {
type, type,
@@ -113,8 +121,18 @@ class WsRequestor extends BaseRequestor {
//this.logger.debug({obj}, `websocket: sending (${url})`); //this.logger.debug({obj}, `websocket: sending (${url})`);
/* special case: reconnecting before we received ack to session:new */
let reconnectingWithoutAck = false;
if (type === 'session:reconnect' && this._initMsgId) {
reconnectingWithoutAck = true;
const obj = this.messagesInFlight.get(this._initMsgId);
this.messagesInFlight.delete(this._initMsgId);
this.messagesInFlight.set(msgid, obj);
this._initMsgId = msgid;
}
/* simple notifications */ /* simple notifications */
if (['call:status', 'jambonz:error', 'session:reconnect'].includes(type)) { if (['call:status', 'verb:status', 'jambonz:error'].includes(type) || reconnectingWithoutAck) {
this.ws.send(JSON.stringify(obj), () => { this.ws.send(JSON.stringify(obj), () => {
this.logger.debug({obj}, `WsRequestor:request websocket: sent (${url})`); this.logger.debug({obj}, `WsRequestor:request websocket: sent (${url})`);
sendQueuedMsgs(); sendQueuedMsgs();
@@ -126,7 +144,7 @@ class WsRequestor extends BaseRequestor {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
/* give the far end a reasonable amount of time to ack our message */ /* give the far end a reasonable amount of time to ack our message */
const timer = setTimeout(() => { const timer = setTimeout(() => {
const {failure} = this.messagesInFlight.get(msgid); const {failure} = this.messagesInFlight.get(msgid) || {};
failure && failure(`timeout from far end for msgid ${msgid}`); failure && failure(`timeout from far end for msgid ${msgid}`);
this.messagesInFlight.delete(msgid); this.messagesInFlight.delete(msgid);
}, RESPONSE_TIMEOUT_MS); }, RESPONSE_TIMEOUT_MS);
@@ -138,7 +156,7 @@ class WsRequestor extends BaseRequestor {
success: (response) => { success: (response) => {
clearTimeout(timer); clearTimeout(timer);
const rtt = this._roundTrip(startAt); 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']); this.stats.histogram('app.hook.ws_response_time', rtt, ['hook_type:app']);
resolve(response); resolve(response);
}, },
@@ -161,17 +179,11 @@ class WsRequestor extends BaseRequestor {
this.logger.debug('WsRequestor:close closing socket'); this.logger.debug('WsRequestor:close closing socket');
try { try {
if (this.ws) { if (this.ws) {
this.ws.close(); this.ws.close(1000);
this.ws.removeAllListeners(); this.ws.removeAllListeners();
this.ws = null;
} }
this._clearPendingMessages();
for (const [msgid, obj] of this.messagesInFlight) {
const {timer} = obj;
clearTimeout(timer);
obj.failure(`abandoning msgid ${msgid} since we have closed the socket`);
}
this.messagesInFlight.clear();
} catch (err) { } catch (err) {
this.logger.info({err}, 'WsRequestor: Error closing socket'); this.logger.info({err}, 'WsRequestor: Error closing socket');
} }
@@ -187,7 +199,7 @@ class WsRequestor extends BaseRequestor {
followRedirects: true, followRedirects: true,
maxRedirects: 2, maxRedirects: 2,
handshakeTimeout, 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}`}; if (this.username && this.password) opts = {...opts, auth: `${this.username}:${this.password}`};
@@ -207,7 +219,6 @@ class WsRequestor extends BaseRequestor {
} }
_setHandlers(ws) { _setHandlers(ws) {
this.logger.debug('WsRequestor:_setHandlers');
ws ws
.once('open', this._onOpen.bind(this, ws)) .once('open', this._onOpen.bind(this, ws))
.once('close', this._onClose.bind(this)) .once('close', this._onClose.bind(this))
@@ -216,6 +227,15 @@ class WsRequestor extends BaseRequestor {
.on('error', this._onError.bind(this)); .on('error', this._onError.bind(this));
} }
_clearPendingMessages() {
for (const [msgid, obj] of this.messagesInFlight) {
const {timer} = obj;
clearTimeout(timer);
if (!this._initMsgId) obj.failure(`abandoning msgid ${msgid} since socket is closed`);
}
this.messagesInFlight.clear();
}
_onError(err) { _onError(err) {
if (this.connections > 0) { if (this.connections > 0) {
this.logger.info({url: this.url, err}, 'WsRequestor:_onError'); this.logger.info({url: this.url, err}, 'WsRequestor:_onError');
@@ -253,12 +273,14 @@ class WsRequestor extends BaseRequestor {
}, 'WsRequestor - unexpected response'); }, 'WsRequestor - unexpected response');
this.emit('connection-failure'); this.emit('connection-failure');
this.emit('not-ready', new Error(`${res.statusCode} ${res.statusMessage}`)); this.emit('not-ready', new Error(`${res.statusCode} ${res.statusMessage}`));
this.connections++;
} }
_onSocketClosed() { _onSocketClosed() {
this.ws = null; this.ws = null;
this.emit('connection-dropped'); this.emit('connection-dropped');
if (this.connections > 0 && this.connections < MAX_RECONNECTS && !this.closedGracefully) { if (this.connections > 0 && this.connections < MAX_RECONNECTS && !this.closedGracefully) {
if (!this._initMsgId) this._clearPendingMessages();
this.logger.debug(`WsRequestor:_onSocketClosed waiting ${this.backoffMs} to reconnect`); this.logger.debug(`WsRequestor:_onSocketClosed waiting ${this.backoffMs} to reconnect`);
setTimeout(() => { setTimeout(() => {
this.logger.debug( this.logger.debug(
@@ -286,7 +308,7 @@ class WsRequestor extends BaseRequestor {
const obj = JSON.parse(content); const obj = JSON.parse(content);
const {type, msgid, command, call_sid = this.call_sid, queueCommand = false, data} = obj; 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'); assert.ok(type, 'type property not supplied');
switch (type) { switch (type) {
@@ -310,12 +332,13 @@ class WsRequestor extends BaseRequestor {
} }
_recvAck(msgid, data) { _recvAck(msgid, data) {
this._initMsgId = null;
const obj = this.messagesInFlight.get(msgid); const obj = this.messagesInFlight.get(msgid);
if (!obj) { if (!obj) {
this.logger.info({url: this.url}, `WsRequestor:_recvAck - ack to unknown msgid ${msgid}, discarding`); this.logger.info({url: this.url}, `WsRequestor:_recvAck - ack to unknown msgid ${msgid}, discarding`);
return; return;
} }
this.logger.debug({url: this.url}, `WsRequestor:_recvAck - received response to ${msgid}`); //this.logger.debug({url: this.url}, `WsRequestor:_recvAck - received response to ${msgid}`);
this.messagesInFlight.delete(msgid); this.messagesInFlight.delete(msgid);
const {success} = obj; const {success} = obj;
success && success(data); success && success(data);
@@ -323,7 +346,7 @@ class WsRequestor extends BaseRequestor {
_recvCommand(msgid, command, call_sid, queueCommand, data) { _recvCommand(msgid, command, call_sid, queueCommand, data) {
// TODO: validate command // 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}); this.emit('command', {msgid, command, call_sid, queueCommand, data});
} }
} }

9822
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "jambonz-feature-server", "name": "jambonz-feature-server",
"version": "v0.7.5", "version": "v0.8.1",
"main": "app.js", "main": "app.js",
"engines": { "engines": {
"node": ">= 10.16.0" "node": ">= 10.16.0"
@@ -16,49 +16,51 @@
"type": "git", "type": "git",
"url": "https://github.com/jambonz/jambonz-feature-server.git" "url": "https://github.com/jambonz/jambonz-feature-server.git"
}, },
"bugs": { "bugs": {},
"url": "https://github.com/jambonz/jambonz-feature-server/issues"
},
"scripts": { "scripts": {
"start": "node app", "start": "node app",
"test": "NODE_ENV=test JAMBONES_HOSTING=1 HTTP_POOL=1 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/ ", "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:JambonzR0ck$:docker-host JAMBONES_TIME_SERIES_HOST=127.0.0.1 JAMBONES_NETWORK_CIDR=172.38.0.0/16 node test/ ",
"coverage": "./node_modules/.bin/nyc --reporter html --report-dir ./coverage npm run test", "coverage": "./node_modules/.bin/nyc --reporter html --report-dir ./coverage npm run test",
"jslint": "eslint app.js lib" "jslint": "eslint app.js lib"
}, },
"dependencies": { "dependencies": {
"@jambonz/db-helpers": "^0.6.18", "@jambonz/db-helpers": "^0.7.4",
"@jambonz/http-health-check": "^0.0.1", "@jambonz/http-health-check": "^0.0.1",
"@jambonz/realtimedb-helpers": "^0.4.29", "@jambonz/realtimedb-helpers": "^0.7.0",
"@jambonz/speech-utils": "^0.0.6",
"@jambonz/stats-collector": "^0.1.6", "@jambonz/stats-collector": "^0.1.6",
"@jambonz/time-series": "^0.1.9", "@jambonz/time-series": "^0.2.5",
"@opentelemetry/api": "^1.1.0", "@jambonz/verb-specifications": "^0.0.11",
"@opentelemetry/exporter-jaeger": "^1.3.1", "@opentelemetry/api": "^1.4.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.27.0", "@opentelemetry/exporter-jaeger": "^1.9.0",
"@opentelemetry/exporter-zipkin": "^1.3.1", "@opentelemetry/exporter-trace-otlp-http": "^0.35.0",
"@opentelemetry/instrumentation": "^0.27.0", "@opentelemetry/exporter-zipkin": "^1.9.0",
"@opentelemetry/resources": "^1.3.1", "@opentelemetry/instrumentation": "^0.35.0",
"@opentelemetry/sdk-trace-base": "^1.3.1", "@opentelemetry/resources": "^1.9.0",
"@opentelemetry/sdk-trace-node": "^1.3.1", "@opentelemetry/sdk-trace-base": "^1.9.0",
"@opentelemetry/semantic-conventions": "^1.3.1", "@opentelemetry/sdk-trace-node": "^1.9.0",
"aws-sdk": "^2.1152.0", "@opentelemetry/semantic-conventions": "^1.9.0",
"aws-sdk": "^2.1313.0",
"bent": "^7.3.12", "bent": "^7.3.12",
"debug": "^4.3.4", "debug": "^4.3.4",
"deepcopy": "^2.1.0", "deepcopy": "^2.1.0",
"drachtio-fsmrf": "^3.0.2", "drachtio-fsmrf": "^3.0.20",
"drachtio-srf": "^4.5.1", "drachtio-srf": "^4.5.23",
"express": "^4.18.1", "express": "^4.18.2",
"helmet": "^5.1.0",
"ip": "^1.1.8", "ip": "^1.1.8",
"moment": "^2.29.3", "moment": "^2.29.4",
"parse-url": "^7.0.2", "parse-url": "^8.1.0",
"pino": "^6.14.0", "pino": "^8.8.0",
"polly-ssml-split": "^0.1.0",
"proxyquire": "^2.1.3",
"sdp-transform": "^2.14.1", "sdp-transform": "^2.14.1",
"short-uuid": "^4.2.0", "short-uuid": "^4.2.2",
"sinon": "^15.0.1",
"to-snake-case": "^1.0.0", "to-snake-case": "^1.0.0",
"undici": "^5.8.0", "undici": "^5.19.1",
"uuid": "^8.3.2", "uuid-random": "^1.3.2",
"verify-aws-sns-signature": "^0.0.7", "verify-aws-sns-signature": "^0.1.0",
"ws": "^8.8.0", "ws": "^8.9.0",
"xml2js": "^0.4.23" "xml2js": "^0.4.23"
}, },
"devDependencies": { "devDependencies": {
@@ -66,7 +68,7 @@
"eslint": "^7.32.0", "eslint": "^7.32.0",
"eslint-plugin-promise": "^4.3.1", "eslint-plugin-promise": "^4.3.1",
"nyc": "^15.1.0", "nyc": "^15.1.0",
"tape": "^5.5.3" "tape": "^5.6.1"
}, },
"optionalDependencies": { "optionalDependencies": {
"bufferutil": "^4.0.6", "bufferutil": "^4.0.6",

102
test/config-test.js Normal file
View File

@@ -0,0 +1,102 @@
const test = require('tape');
const { sippUac } = require('./sipp')('test_fs');
const bent = require('bent');
const getJSON = bent('json')
const clearModule = require('clear-module');
const {provisionCallHook} = require('./utils')
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
function connect(connectable) {
return new Promise((resolve, reject) => {
connectable.on('connect', () => {
return resolve();
});
});
}
test('\'config: listen\'', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
const from = "config_listen_success";
let verbs = [
{
"verb": "config",
"listen": {
"enable": true,
"url": `ws://172.38.0.60:3000/${from}`
}
},
{
"verb": "pause",
"length": 5
}
];
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success-send-bye.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/ws_packet_count/${from}`);
t.pass('config: successfully started background listen');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'config: listen - stop\'', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
const from = "config_listen_success";
let verbs = [
{
"verb": "config",
"listen": {
"enable": true,
"url": `ws://172.38.0.60:3000/${from}`
}
},
{
"verb": "pause",
"length": 1
},
{
"verb": "config",
"listen": {
"enable": false
}
},
{
"verb": "pause",
"length": 3
}
];
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success-send-bye.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/ws_packet_count/${from}`);
t.pass('config: successfully started then stopped background listen');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});

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

@@ -22,11 +22,17 @@ test('creating schema', (t) => {
const google_credential = encrypt(process.env.GCP_JSON_KEY); const google_credential = encrypt(process.env.GCP_JSON_KEY);
const aws_credential = encrypt(JSON.stringify({ const aws_credential = encrypt(JSON.stringify({
access_key_id: process.env.AWS_ACCESS_KEY_ID, access_key_id: process.env.AWS_ACCESS_KEY_ID,
secret_access_key: process.env.AWS_SECRET_ACCESS_KEY secret_access_key: process.env.AWS_SECRET_ACCESS_KEY,
aws_region: process.env.AWS_REGION
}));
const microsoft_credential = encrypt(JSON.stringify({
region: process.env.MICROSOFT_REGION || 'useast',
api_key: process.env.MICROSOFT_API_KEY || '1234567890'
})); }));
const cmd = ` const cmd = `
UPDATE speech_credentials SET credential='${google_credential}' WHERE vendor='google'; UPDATE speech_credentials SET credential='${google_credential}' WHERE vendor='google';
UPDATE speech_credentials SET credential='${aws_credential}' WHERE vendor='aws'; UPDATE speech_credentials SET credential='${aws_credential}' WHERE vendor='aws';
UPDATE speech_credentials SET credential='${microsoft_credential}' WHERE vendor='microsoft';
`; `;
const path = `${__dirname}/.creds.sql`; const path = `${__dirname}/.creds.sql`;
fs.writeFileSync(path, cmd); fs.writeFileSync(path, cmd);

View File

@@ -0,0 +1,9 @@
{
"say": {
"text": "<speak>I already told you <emphasis level=\"strong\">I already told you I already told you I already told you I already told you! I already told you I already told you I already told you I already told you? I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you told I already told you I already told you told I already told you I already told you. I already told you <break time=\"3s\"/> I really like that person!</emphasis> this is another long text.</speak>",
"synthesizer": {
"vendor": "google",
"language": "en-US"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"say": {
"text": "<speak>I already told you I already told you I already told you I already told you I already told you! I already told you I already told you I already told you I already told you? I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you I already told you told I already told you I already told you told I already told you I already told you. I already told you <break time=\"3s\"/> I <emphasis level=\"strong\">really like that person!</emphasis> this is another long text.</speak>",
"synthesizer": {
"vendor": "google",
"language": "en-US"
}
}
}

View File

@@ -217,6 +217,7 @@ CREATE TABLE `applications` (
`call_hook_sid` char(36) DEFAULT NULL COMMENT 'webhook to call for inbound calls ', `call_hook_sid` char(36) DEFAULT NULL COMMENT 'webhook to call for inbound calls ',
`call_status_hook_sid` char(36) DEFAULT NULL COMMENT 'webhook to call for call status events', `call_status_hook_sid` char(36) DEFAULT NULL COMMENT 'webhook to call for call status events',
`messaging_hook_sid` char(36) DEFAULT NULL COMMENT 'webhook to call for inbound SMS/MMS ', `messaging_hook_sid` char(36) DEFAULT NULL COMMENT 'webhook to call for inbound SMS/MMS ',
`app_json` VARCHAR(16384),
`speech_synthesis_vendor` varchar(64) NOT NULL DEFAULT 'google', `speech_synthesis_vendor` varchar(64) NOT NULL DEFAULT 'google',
`speech_synthesis_language` varchar(12) NOT NULL DEFAULT 'en-US', `speech_synthesis_language` varchar(12) NOT NULL DEFAULT 'en-US',
`speech_synthesis_voice` varchar(64) DEFAULT NULL, `speech_synthesis_voice` varchar(64) DEFAULT NULL,
@@ -245,13 +246,14 @@ CREATE TABLE `applications` (
LOCK TABLES `applications` WRITE; LOCK TABLES `applications` WRITE;
/*!40000 ALTER TABLE `applications` DISABLE KEYS */; /*!40000 ALTER TABLE `applications` DISABLE KEYS */;
INSERT INTO `applications` VALUES ('0dddaabf-0a30-43e3-84e8-426873b1a78b','decline call',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','c71e79db-24f2-4866-a3ee-febb0f97b341','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US'); INSERT INTO `applications` VALUES ('0dddaabf-0a30-43e3-84e8-426873b1a78b','decline call',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','c71e79db-24f2-4866-a3ee-febb0f97b341','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,NULL,'google','en-US','en-US-Standard-C','google','en-US');
INSERT INTO `applications` VALUES ('308b4f41-1a18-4052-b89a-c054e75ce242','say',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','54ab0976-a6c0-45d8-89a4-d90d45bf9d96','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US'); INSERT INTO `applications` VALUES ('308b4f41-1a18-4052-b89a-c054e75ce242','say',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','54ab0976-a6c0-45d8-89a4-d90d45bf9d96','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,NULL,'google','en-US','en-US-Standard-C','google','en-US');
INSERT INTO `applications` VALUES ('24d0f6af-e976-44dd-a2e8-41c7b55abe33','say account 2',NULL,'622f62e4-303a-49f2-bbe0-eb1e1714e37a','54ab0976-a6c0-45d8-89a4-d90d45bf9d96','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US'); INSERT INTO `applications` VALUES ('24d0f6af-e976-44dd-a2e8-41c7b55abe33','say account 2',NULL,'622f62e4-303a-49f2-bbe0-eb1e1714e37a','54ab0976-a6c0-45d8-89a4-d90d45bf9d96','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,NULL,'google','en-US','en-US-Standard-C','google','en-US');
INSERT INTO `applications` VALUES ('17461c69-56b5-4dab-ad83-1c43a0f93a3d','gather',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','10692465-a511-4277-9807-b7157e4f81e1','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US'); INSERT INTO `applications` VALUES ('17461c69-56b5-4dab-ad83-1c43a0f93a3d','gather',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','10692465-a511-4277-9807-b7157e4f81e1','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,NULL,'google','en-US','en-US-Standard-C','google','en-US');
INSERT INTO `applications` VALUES ('baf9213b-5556-4c20-870c-586392ed246f','transcribe',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','ecb67a8f-f7ce-4919-abf0-bbc69c1001e5','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US'); INSERT INTO `applications` VALUES ('baf9213b-5556-4c20-870c-586392ed246f','transcribe',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','ecb67a8f-f7ce-4919-abf0-bbc69c1001e5','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,NULL,'google','en-US','en-US-Standard-C','google','en-US');
INSERT INTO `applications` VALUES ('ae026ab5-3029-47b4-9d7c-236e3a4b4ebe','transcribe account 2',NULL,'622f62e4-303a-49f2-bbe0-eb1e1714e37a','ecb67a8f-f7ce-4919-abf0-bbc69c1001e5','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US'); INSERT INTO `applications` VALUES ('ae026ab5-3029-47b4-9d7c-236e3a4b4ebe','transcribe account 2',NULL,'622f62e4-303a-49f2-bbe0-eb1e1714e37a','ecb67a8f-f7ce-4919-abf0-bbc69c1001e5','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,NULL,'google','en-US','en-US-Standard-C','google','en-US');
INSERT INTO `applications` VALUES ('195d9507-6a42-46a8-825f-f009e729d023','sip info',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','c9113e7a-741f-48b9-96c1-f2f78176eeb3','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'google','en-US','en-US-Standard-C','google','en-US'); INSERT INTO `applications` VALUES ('195d9507-6a42-46a8-825f-f009e729d023','sip info',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','c9113e7a-741f-48b9-96c1-f2f78176eeb3','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,NULL,'google','en-US','en-US-Standard-C','google','en-US');
INSERT INTO `applications` VALUES ('0dddaabf-0a30-43e3-84e8-426873b1a78c','app json',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','c71e79db-24f2-4866-a3ee-febb0f97b341','293904c1-351b-4bca-8d58-1a29b853c7db',NULL,'[{"verb": "play","url": "silence_stream://5000"}]','google','en-US','en-US-Standard-C','google','en-US');
/*!40000 ALTER TABLE `applications` ENABLE KEYS */; /*!40000 ALTER TABLE `applications` ENABLE KEYS */;
UNLOCK TABLES; UNLOCK TABLES;
@@ -451,6 +453,7 @@ INSERT INTO `phone_numbers` VALUES ('05eeed62-b29b-4679-bf38-d7a4e318be44','1617
INSERT INTO `phone_numbers` VALUES ('f3c53863-b629-4cf6-9dcb-c7fb7072314b','16174000004','5145b436-2f38-4029-8d4c-fd8c67831c7a','bb845d4b-83a9-4cde-a6e9-50f3743bab3f','baf9213b-5556-4c20-870c-586392ed246f', NULL); INSERT INTO `phone_numbers` VALUES ('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 ('f6416c17-829a-4f11-9c32-f0d00e4a9ae9','16174000005','5145b436-2f38-4029-8d4c-fd8c67831c7a','622f62e4-303a-49f2-bbe0-eb1e1714e37a','ae026ab5-3029-47b4-9d7c-236e3a4b4ebe', NULL);
INSERT INTO `phone_numbers` VALUES ('964d0581-9627-44cb-be20-8118050406b2','16174000006','5145b436-2f38-4029-8d4c-fd8c67831c7a','bb845d4b-83a9-4cde-a6e9-50f3743bab3f','195d9507-6a42-46a8-825f-f009e729d023', NULL); INSERT INTO `phone_numbers` VALUES ('964d0581-9627-44cb-be20-8118050406b2','16174000006','5145b436-2f38-4029-8d4c-fd8c67831c7a','bb845d4b-83a9-4cde-a6e9-50f3743bab3f','195d9507-6a42-46a8-825f-f009e729d023', NULL);
INSERT INTO `phone_numbers` VALUES ('964d0581-9627-44cb-be20-8118050406b3','16174000007','5145b436-2f38-4029-8d4c-fd8c67831c7a','bb845d4b-83a9-4cde-a6e9-50f3743bab3f','0dddaabf-0a30-43e3-84e8-426873b1a78c', NULL);
/*!40000 ALTER TABLE `phone_numbers` ENABLE KEYS */; /*!40000 ALTER TABLE `phone_numbers` ENABLE KEYS */;
UNLOCK TABLES; UNLOCK TABLES;
@@ -614,7 +617,10 @@ CREATE TABLE `speech_credentials` (
LOCK TABLES `speech_credentials` WRITE; LOCK TABLES `speech_credentials` WRITE;
/*!40000 ALTER TABLE `speech_credentials` DISABLE KEYS */; /*!40000 ALTER TABLE `speech_credentials` DISABLE KEYS */;
INSERT INTO `speech_credentials` VALUES ('2add163c-34f2-45c6-a016-f955d218ffb6',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','google','credential-goes-here',1,1,NULL,'2021-04-03 15:42:10',1,1),('84154212-5c99-4c94-8993-bc2a46288daa',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','aws','credential-goes-here',0,0,NULL,NULL,NULL,NULL); INSERT INTO `speech_credentials` VALUES
('2add163c-34f2-45c6-a016-f955d218ffb6',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','google','credential-goes-here',1,1,NULL,'2021-04-03 15:42:10',1,1),
('2add347f-34f2-45c6-a016-f955d218ffb6',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','microsoft','credential-goes-here',1,1,NULL,'2021-04-03 15:42:10',1,1),
('84154212-5c99-4c94-8993-bc2a46288daa',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','aws','credential-goes-here',1,1,NULL,NULL,NULL,NULL);
/*!40000 ALTER TABLE `speech_credentials` ENABLE KEYS */; /*!40000 ALTER TABLE `speech_credentials` ENABLE KEYS */;
UNLOCK TABLES; UNLOCK TABLES;

View File

@@ -4,6 +4,8 @@ SET FOREIGN_KEY_CHECKS=0;
DROP TABLE IF EXISTS account_static_ips; DROP TABLE IF EXISTS account_static_ips;
DROP TABLE IF EXISTS account_limits;
DROP TABLE IF EXISTS account_products; DROP TABLE IF EXISTS account_products;
DROP TABLE IF EXISTS account_subscriptions; DROP TABLE IF EXISTS account_subscriptions;
@@ -18,6 +20,12 @@ DROP TABLE IF EXISTS lcr_carrier_set_entry;
DROP TABLE IF EXISTS lcr_routes; DROP TABLE IF EXISTS lcr_routes;
DROP TABLE IF EXISTS password_settings;
DROP TABLE IF EXISTS user_permissions;
DROP TABLE IF EXISTS permissions;
DROP TABLE IF EXISTS predefined_sip_gateways; DROP TABLE IF EXISTS predefined_sip_gateways;
DROP TABLE IF EXISTS predefined_smpp_gateways; DROP TABLE IF EXISTS predefined_smpp_gateways;
@@ -36,6 +44,8 @@ DROP TABLE IF EXISTS sbc_addresses;
DROP TABLE IF EXISTS ms_teams_tenants; DROP TABLE IF EXISTS ms_teams_tenants;
DROP TABLE IF EXISTS service_provider_limits;
DROP TABLE IF EXISTS signup_history; DROP TABLE IF EXISTS signup_history;
DROP TABLE IF EXISTS smpp_addresses; DROP TABLE IF EXISTS smpp_addresses;
@@ -69,6 +79,15 @@ private_ipv4 VARBINARY(16) NOT NULL UNIQUE ,
PRIMARY KEY (account_static_ip_sid) PRIMARY KEY (account_static_ip_sid)
); );
CREATE TABLE account_limits
(
account_limits_sid CHAR(36) NOT NULL UNIQUE ,
account_sid CHAR(36) NOT NULL,
category ENUM('api_rate','voice_call_session', 'device','voice_call_minutes','voice_call_session_license', 'voice_call_minutes_license') NOT NULL,
quantity INTEGER NOT NULL,
PRIMARY KEY (account_limits_sid)
);
CREATE TABLE account_subscriptions CREATE TABLE account_subscriptions
( (
account_subscription_sid CHAR(36) NOT NULL UNIQUE , account_subscription_sid CHAR(36) NOT NULL UNIQUE ,
@@ -123,6 +142,21 @@ priority INTEGER NOT NULL UNIQUE COMMENT 'lower priority routes are attempted f
PRIMARY KEY (lcr_route_sid) PRIMARY KEY (lcr_route_sid)
) COMMENT='Least cost routing table'; ) COMMENT='Least cost routing table';
CREATE TABLE password_settings
(
min_password_length INTEGER NOT NULL DEFAULT 8,
require_digit BOOLEAN NOT NULL DEFAULT false,
require_special_character BOOLEAN NOT NULL DEFAULT false
);
CREATE TABLE permissions
(
permission_sid CHAR(36) NOT NULL UNIQUE ,
name VARCHAR(32) NOT NULL UNIQUE ,
description VARCHAR(255),
PRIMARY KEY (permission_sid)
);
CREATE TABLE predefined_carriers CREATE TABLE predefined_carriers
( (
predefined_carrier_sid CHAR(36) NOT NULL UNIQUE , predefined_carrier_sid CHAR(36) NOT NULL UNIQUE ,
@@ -228,6 +262,15 @@ tenant_fqdn VARCHAR(255) NOT NULL UNIQUE ,
PRIMARY KEY (ms_teams_tenant_sid) PRIMARY KEY (ms_teams_tenant_sid)
) COMMENT='A Microsoft Teams customer tenant'; ) COMMENT='A Microsoft Teams customer tenant';
CREATE TABLE service_provider_limits
(
service_provider_limits_sid CHAR(36) NOT NULL UNIQUE ,
service_provider_sid CHAR(36) NOT NULL,
category ENUM('api_rate','voice_call_session', 'device','voice_call_minutes','voice_call_session_license', 'voice_call_minutes_license') NOT NULL,
quantity INTEGER NOT NULL,
PRIMARY KEY (service_provider_limits_sid)
);
CREATE TABLE signup_history CREATE TABLE signup_history
( (
email VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL,
@@ -283,6 +326,7 @@ email_activation_code VARCHAR(16),
email_validated BOOLEAN NOT NULL DEFAULT false, email_validated BOOLEAN NOT NULL DEFAULT false,
phone_validated BOOLEAN NOT NULL DEFAULT false, phone_validated BOOLEAN NOT NULL DEFAULT false,
email_content_opt_out BOOLEAN NOT NULL DEFAULT false, email_content_opt_out BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true,
PRIMARY KEY (user_sid) PRIMARY KEY (user_sid)
); );
@@ -310,9 +354,20 @@ smpp_password VARCHAR(64),
smpp_enquire_link_interval INTEGER DEFAULT 0, smpp_enquire_link_interval INTEGER DEFAULT 0,
smpp_inbound_system_id VARCHAR(255), smpp_inbound_system_id VARCHAR(255),
smpp_inbound_password VARCHAR(64), smpp_inbound_password VARCHAR(64),
register_from_user VARCHAR(128),
register_from_domain VARCHAR(255),
register_public_ip_in_contact BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (voip_carrier_sid) PRIMARY KEY (voip_carrier_sid)
) COMMENT='A Carrier or customer PBX that can send or receive calls'; ) COMMENT='A Carrier or customer PBX that can send or receive calls';
CREATE TABLE user_permissions
(
user_permissions_sid CHAR(36) NOT NULL UNIQUE ,
user_sid CHAR(36) NOT NULL,
permission_sid CHAR(36) NOT NULL,
PRIMARY KEY (user_permissions_sid)
);
CREATE TABLE smpp_gateways CREATE TABLE smpp_gateways
( (
smpp_gateway_sid CHAR(36) NOT NULL UNIQUE , smpp_gateway_sid CHAR(36) NOT NULL UNIQUE ,
@@ -330,7 +385,7 @@ PRIMARY KEY (smpp_gateway_sid)
CREATE TABLE phone_numbers CREATE TABLE phone_numbers
( (
phone_number_sid CHAR(36) UNIQUE , phone_number_sid CHAR(36) UNIQUE ,
number VARCHAR(32) NOT NULL UNIQUE , number VARCHAR(132) NOT NULL UNIQUE ,
voip_carrier_sid CHAR(36), voip_carrier_sid CHAR(36),
account_sid CHAR(36), account_sid CHAR(36),
application_sid CHAR(36), application_sid CHAR(36),
@@ -380,6 +435,7 @@ account_sid CHAR(36) COMMENT 'account that this application belongs to (if null,
call_hook_sid CHAR(36) COMMENT 'webhook to call for inbound calls ', call_hook_sid CHAR(36) COMMENT 'webhook to call for inbound calls ',
call_status_hook_sid CHAR(36) COMMENT 'webhook to call for call status events', call_status_hook_sid CHAR(36) COMMENT 'webhook to call for call status events',
messaging_hook_sid CHAR(36) COMMENT 'webhook to call for inbound SMS/MMS ', messaging_hook_sid CHAR(36) COMMENT 'webhook to call for inbound SMS/MMS ',
app_json VARCHAR(16384),
speech_synthesis_vendor VARCHAR(64) NOT NULL DEFAULT 'google', speech_synthesis_vendor VARCHAR(64) NOT NULL DEFAULT 'google',
speech_synthesis_language VARCHAR(12) NOT NULL DEFAULT 'en-US', speech_synthesis_language VARCHAR(12) NOT NULL DEFAULT 'en-US',
speech_synthesis_voice VARCHAR(64), speech_synthesis_voice VARCHAR(64),
@@ -418,6 +474,11 @@ disable_cdrs BOOLEAN NOT NULL DEFAULT 0,
trial_end_date DATETIME, trial_end_date DATETIME,
deactivated_reason VARCHAR(255), deactivated_reason VARCHAR(255),
device_to_call_ratio INTEGER NOT NULL DEFAULT 5, device_to_call_ratio INTEGER NOT NULL DEFAULT 5,
subspace_client_id VARCHAR(255),
subspace_client_secret VARCHAR(255),
subspace_sip_teleport_id VARCHAR(255),
subspace_sip_teleport_destinations VARCHAR(255),
siprec_hook_sid CHAR(36),
PRIMARY KEY (account_sid) PRIMARY KEY (account_sid)
) COMMENT='An enterprise that uses the platform for comm services'; ) COMMENT='An enterprise that uses the platform for comm services';
@@ -425,19 +486,23 @@ CREATE INDEX account_static_ip_sid_idx ON account_static_ips (account_static_ip_
CREATE INDEX account_sid_idx ON account_static_ips (account_sid); CREATE INDEX account_sid_idx ON account_static_ips (account_sid);
ALTER TABLE account_static_ips ADD FOREIGN KEY account_sid_idxfk (account_sid) REFERENCES accounts (account_sid); ALTER TABLE account_static_ips ADD FOREIGN KEY account_sid_idxfk (account_sid) REFERENCES accounts (account_sid);
CREATE INDEX account_sid_idx ON account_limits (account_sid);
ALTER TABLE account_limits ADD FOREIGN KEY account_sid_idxfk_1 (account_sid) REFERENCES accounts (account_sid) ON DELETE CASCADE;
CREATE INDEX account_subscription_sid_idx ON account_subscriptions (account_subscription_sid); CREATE INDEX account_subscription_sid_idx ON account_subscriptions (account_subscription_sid);
CREATE INDEX account_sid_idx ON account_subscriptions (account_sid); CREATE INDEX account_sid_idx ON account_subscriptions (account_sid);
ALTER TABLE account_subscriptions ADD FOREIGN KEY account_sid_idxfk_1 (account_sid) REFERENCES accounts (account_sid); ALTER TABLE account_subscriptions ADD FOREIGN KEY account_sid_idxfk_2 (account_sid) REFERENCES accounts (account_sid);
CREATE INDEX invite_code_idx ON beta_invite_codes (invite_code); CREATE INDEX invite_code_idx ON beta_invite_codes (invite_code);
CREATE INDEX call_route_sid_idx ON call_routes (call_route_sid); CREATE INDEX call_route_sid_idx ON call_routes (call_route_sid);
ALTER TABLE call_routes ADD FOREIGN KEY account_sid_idxfk_2 (account_sid) REFERENCES accounts (account_sid); ALTER TABLE call_routes ADD FOREIGN KEY account_sid_idxfk_3 (account_sid) REFERENCES accounts (account_sid);
ALTER TABLE call_routes ADD FOREIGN KEY application_sid_idxfk (application_sid) REFERENCES applications (application_sid); ALTER TABLE call_routes ADD FOREIGN KEY application_sid_idxfk (application_sid) REFERENCES applications (application_sid);
CREATE INDEX dns_record_sid_idx ON dns_records (dns_record_sid); CREATE INDEX dns_record_sid_idx ON dns_records (dns_record_sid);
ALTER TABLE dns_records ADD FOREIGN KEY account_sid_idxfk_3 (account_sid) REFERENCES accounts (account_sid); ALTER TABLE dns_records ADD FOREIGN KEY account_sid_idxfk_4 (account_sid) REFERENCES accounts (account_sid);
CREATE INDEX permission_sid_idx ON permissions (permission_sid);
CREATE INDEX predefined_carrier_sid_idx ON predefined_carriers (predefined_carrier_sid); CREATE INDEX predefined_carrier_sid_idx ON predefined_carriers (predefined_carrier_sid);
CREATE INDEX predefined_sip_gateway_sid_idx ON predefined_sip_gateways (predefined_sip_gateway_sid); CREATE INDEX predefined_sip_gateway_sid_idx ON predefined_sip_gateways (predefined_sip_gateway_sid);
CREATE INDEX predefined_carrier_sid_idx ON predefined_sip_gateways (predefined_carrier_sid); CREATE INDEX predefined_carrier_sid_idx ON predefined_sip_gateways (predefined_carrier_sid);
@@ -456,14 +521,14 @@ ALTER TABLE account_products ADD FOREIGN KEY product_sid_idxfk (product_sid) REF
CREATE INDEX account_offer_sid_idx ON account_offers (account_offer_sid); CREATE INDEX account_offer_sid_idx ON account_offers (account_offer_sid);
CREATE INDEX account_sid_idx ON account_offers (account_sid); CREATE INDEX account_sid_idx ON account_offers (account_sid);
ALTER TABLE account_offers ADD FOREIGN KEY account_sid_idxfk_4 (account_sid) REFERENCES accounts (account_sid); ALTER TABLE account_offers ADD FOREIGN KEY account_sid_idxfk_5 (account_sid) REFERENCES accounts (account_sid);
CREATE INDEX product_sid_idx ON account_offers (product_sid); CREATE INDEX product_sid_idx ON account_offers (product_sid);
ALTER TABLE account_offers ADD FOREIGN KEY product_sid_idxfk_1 (product_sid) REFERENCES products (product_sid); ALTER TABLE account_offers ADD FOREIGN KEY product_sid_idxfk_1 (product_sid) REFERENCES products (product_sid);
CREATE INDEX api_key_sid_idx ON api_keys (api_key_sid); CREATE INDEX api_key_sid_idx ON api_keys (api_key_sid);
CREATE INDEX account_sid_idx ON api_keys (account_sid); CREATE INDEX account_sid_idx ON api_keys (account_sid);
ALTER TABLE api_keys ADD FOREIGN KEY account_sid_idxfk_5 (account_sid) REFERENCES accounts (account_sid); ALTER TABLE api_keys ADD FOREIGN KEY account_sid_idxfk_6 (account_sid) REFERENCES accounts (account_sid);
CREATE INDEX service_provider_sid_idx ON api_keys (service_provider_sid); CREATE INDEX service_provider_sid_idx ON api_keys (service_provider_sid);
ALTER TABLE api_keys ADD FOREIGN KEY service_provider_sid_idxfk (service_provider_sid) REFERENCES service_providers (service_provider_sid); ALTER TABLE api_keys ADD FOREIGN KEY service_provider_sid_idxfk (service_provider_sid) REFERENCES service_providers (service_provider_sid);
@@ -477,44 +542,53 @@ ALTER TABLE sbc_addresses ADD FOREIGN KEY service_provider_sid_idxfk_1 (service_
CREATE INDEX ms_teams_tenant_sid_idx ON ms_teams_tenants (ms_teams_tenant_sid); CREATE INDEX ms_teams_tenant_sid_idx ON ms_teams_tenants (ms_teams_tenant_sid);
ALTER TABLE ms_teams_tenants ADD FOREIGN KEY service_provider_sid_idxfk_2 (service_provider_sid) REFERENCES service_providers (service_provider_sid); ALTER TABLE ms_teams_tenants ADD FOREIGN KEY service_provider_sid_idxfk_2 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
ALTER TABLE ms_teams_tenants ADD FOREIGN KEY account_sid_idxfk_6 (account_sid) REFERENCES accounts (account_sid); ALTER TABLE ms_teams_tenants ADD FOREIGN KEY account_sid_idxfk_7 (account_sid) REFERENCES accounts (account_sid);
ALTER TABLE ms_teams_tenants ADD FOREIGN KEY application_sid_idxfk_1 (application_sid) REFERENCES applications (application_sid); ALTER TABLE ms_teams_tenants ADD FOREIGN KEY application_sid_idxfk_1 (application_sid) REFERENCES applications (application_sid);
CREATE INDEX tenant_fqdn_idx ON ms_teams_tenants (tenant_fqdn); CREATE INDEX tenant_fqdn_idx ON ms_teams_tenants (tenant_fqdn);
CREATE INDEX service_provider_sid_idx ON service_provider_limits (service_provider_sid);
ALTER TABLE service_provider_limits ADD FOREIGN KEY service_provider_sid_idxfk_3 (service_provider_sid) REFERENCES service_providers (service_provider_sid) ON DELETE CASCADE;
CREATE INDEX email_idx ON signup_history (email); CREATE INDEX email_idx ON signup_history (email);
CREATE INDEX smpp_address_sid_idx ON smpp_addresses (smpp_address_sid); CREATE INDEX smpp_address_sid_idx ON smpp_addresses (smpp_address_sid);
CREATE INDEX service_provider_sid_idx ON smpp_addresses (service_provider_sid); CREATE INDEX service_provider_sid_idx ON smpp_addresses (service_provider_sid);
ALTER TABLE smpp_addresses ADD FOREIGN KEY service_provider_sid_idxfk_3 (service_provider_sid) REFERENCES service_providers (service_provider_sid); ALTER TABLE smpp_addresses ADD FOREIGN KEY service_provider_sid_idxfk_4 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
CREATE UNIQUE INDEX speech_credentials_idx_1 ON speech_credentials (vendor,account_sid); CREATE UNIQUE INDEX speech_credentials_idx_1 ON speech_credentials (vendor,account_sid);
CREATE INDEX speech_credential_sid_idx ON speech_credentials (speech_credential_sid); CREATE INDEX speech_credential_sid_idx ON speech_credentials (speech_credential_sid);
CREATE INDEX service_provider_sid_idx ON speech_credentials (service_provider_sid); CREATE INDEX service_provider_sid_idx ON speech_credentials (service_provider_sid);
ALTER TABLE speech_credentials ADD FOREIGN KEY service_provider_sid_idxfk_4 (service_provider_sid) REFERENCES service_providers (service_provider_sid); ALTER TABLE speech_credentials ADD FOREIGN KEY service_provider_sid_idxfk_5 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
CREATE INDEX account_sid_idx ON speech_credentials (account_sid); CREATE INDEX account_sid_idx ON speech_credentials (account_sid);
ALTER TABLE speech_credentials ADD FOREIGN KEY account_sid_idxfk_7 (account_sid) REFERENCES accounts (account_sid); ALTER TABLE speech_credentials ADD FOREIGN KEY account_sid_idxfk_8 (account_sid) REFERENCES accounts (account_sid);
CREATE INDEX user_sid_idx ON users (user_sid); CREATE INDEX user_sid_idx ON users (user_sid);
CREATE INDEX email_idx ON users (email); CREATE INDEX email_idx ON users (email);
CREATE INDEX phone_idx ON users (phone); CREATE INDEX phone_idx ON users (phone);
CREATE INDEX account_sid_idx ON users (account_sid); CREATE INDEX account_sid_idx ON users (account_sid);
ALTER TABLE users ADD FOREIGN KEY account_sid_idxfk_8 (account_sid) REFERENCES accounts (account_sid); ALTER TABLE users ADD FOREIGN KEY account_sid_idxfk_9 (account_sid) REFERENCES accounts (account_sid);
CREATE INDEX service_provider_sid_idx ON users (service_provider_sid); CREATE INDEX service_provider_sid_idx ON users (service_provider_sid);
ALTER TABLE users ADD FOREIGN KEY service_provider_sid_idxfk_5 (service_provider_sid) REFERENCES service_providers (service_provider_sid); ALTER TABLE users ADD FOREIGN KEY service_provider_sid_idxfk_6 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
CREATE INDEX email_activation_code_idx ON users (email_activation_code); CREATE INDEX email_activation_code_idx ON users (email_activation_code);
CREATE INDEX voip_carrier_sid_idx ON voip_carriers (voip_carrier_sid); CREATE INDEX voip_carrier_sid_idx ON voip_carriers (voip_carrier_sid);
CREATE INDEX account_sid_idx ON voip_carriers (account_sid); CREATE INDEX account_sid_idx ON voip_carriers (account_sid);
ALTER TABLE voip_carriers ADD FOREIGN KEY account_sid_idxfk_9 (account_sid) REFERENCES accounts (account_sid); ALTER TABLE voip_carriers ADD FOREIGN KEY account_sid_idxfk_10 (account_sid) REFERENCES accounts (account_sid);
CREATE INDEX service_provider_sid_idx ON voip_carriers (service_provider_sid); CREATE INDEX service_provider_sid_idx ON voip_carriers (service_provider_sid);
ALTER TABLE voip_carriers ADD FOREIGN KEY service_provider_sid_idxfk_6 (service_provider_sid) REFERENCES service_providers (service_provider_sid); ALTER TABLE voip_carriers ADD FOREIGN KEY service_provider_sid_idxfk_7 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
ALTER TABLE voip_carriers ADD FOREIGN KEY application_sid_idxfk_2 (application_sid) REFERENCES applications (application_sid); ALTER TABLE voip_carriers ADD FOREIGN KEY application_sid_idxfk_2 (application_sid) REFERENCES applications (application_sid);
CREATE INDEX user_permissions_sid_idx ON user_permissions (user_permissions_sid);
CREATE INDEX user_sid_idx ON user_permissions (user_sid);
ALTER TABLE user_permissions ADD FOREIGN KEY user_sid_idxfk (user_sid) REFERENCES users (user_sid) ON DELETE CASCADE;
ALTER TABLE user_permissions ADD FOREIGN KEY permission_sid_idxfk (permission_sid) REFERENCES permissions (permission_sid);
CREATE INDEX smpp_gateway_sid_idx ON smpp_gateways (smpp_gateway_sid); CREATE INDEX smpp_gateway_sid_idx ON smpp_gateways (smpp_gateway_sid);
CREATE INDEX voip_carrier_sid_idx ON smpp_gateways (voip_carrier_sid); CREATE INDEX voip_carrier_sid_idx ON smpp_gateways (voip_carrier_sid);
ALTER TABLE smpp_gateways ADD FOREIGN KEY voip_carrier_sid_idxfk (voip_carrier_sid) REFERENCES voip_carriers (voip_carrier_sid); ALTER TABLE smpp_gateways ADD FOREIGN KEY voip_carrier_sid_idxfk (voip_carrier_sid) REFERENCES voip_carriers (voip_carrier_sid);
@@ -524,12 +598,12 @@ CREATE INDEX number_idx ON phone_numbers (number);
CREATE INDEX voip_carrier_sid_idx ON phone_numbers (voip_carrier_sid); CREATE INDEX voip_carrier_sid_idx ON phone_numbers (voip_carrier_sid);
ALTER TABLE phone_numbers ADD FOREIGN KEY voip_carrier_sid_idxfk_1 (voip_carrier_sid) REFERENCES voip_carriers (voip_carrier_sid); ALTER TABLE phone_numbers ADD FOREIGN KEY voip_carrier_sid_idxfk_1 (voip_carrier_sid) REFERENCES voip_carriers (voip_carrier_sid);
ALTER TABLE phone_numbers ADD FOREIGN KEY account_sid_idxfk_10 (account_sid) REFERENCES accounts (account_sid); ALTER TABLE phone_numbers ADD FOREIGN KEY account_sid_idxfk_11 (account_sid) REFERENCES accounts (account_sid);
ALTER TABLE phone_numbers ADD FOREIGN KEY application_sid_idxfk_3 (application_sid) REFERENCES applications (application_sid); ALTER TABLE phone_numbers ADD FOREIGN KEY application_sid_idxfk_3 (application_sid) REFERENCES applications (application_sid);
CREATE INDEX service_provider_sid_idx ON phone_numbers (service_provider_sid); CREATE INDEX service_provider_sid_idx ON phone_numbers (service_provider_sid);
ALTER TABLE phone_numbers ADD FOREIGN KEY service_provider_sid_idxfk_7 (service_provider_sid) REFERENCES service_providers (service_provider_sid); ALTER TABLE phone_numbers ADD FOREIGN KEY service_provider_sid_idxfk_8 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
CREATE INDEX sip_gateway_idx_hostport ON sip_gateways (ipv4,port); CREATE INDEX sip_gateway_idx_hostport ON sip_gateways (ipv4,port);
@@ -545,10 +619,10 @@ CREATE UNIQUE INDEX applications_idx_name ON applications (account_sid,name);
CREATE INDEX application_sid_idx ON applications (application_sid); CREATE INDEX application_sid_idx ON applications (application_sid);
CREATE INDEX service_provider_sid_idx ON applications (service_provider_sid); CREATE INDEX service_provider_sid_idx ON applications (service_provider_sid);
ALTER TABLE applications ADD FOREIGN KEY service_provider_sid_idxfk_8 (service_provider_sid) REFERENCES service_providers (service_provider_sid); ALTER TABLE applications ADD FOREIGN KEY service_provider_sid_idxfk_9 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
CREATE INDEX account_sid_idx ON applications (account_sid); CREATE INDEX account_sid_idx ON applications (account_sid);
ALTER TABLE applications ADD FOREIGN KEY account_sid_idxfk_11 (account_sid) REFERENCES accounts (account_sid); ALTER TABLE applications ADD FOREIGN KEY account_sid_idxfk_12 (account_sid) REFERENCES accounts (account_sid);
ALTER TABLE applications ADD FOREIGN KEY call_hook_sid_idxfk (call_hook_sid) REFERENCES webhooks (webhook_sid); ALTER TABLE applications ADD FOREIGN KEY call_hook_sid_idxfk (call_hook_sid) REFERENCES webhooks (webhook_sid);
@@ -564,7 +638,7 @@ ALTER TABLE service_providers ADD FOREIGN KEY registration_hook_sid_idxfk (regis
CREATE INDEX account_sid_idx ON accounts (account_sid); CREATE INDEX account_sid_idx ON accounts (account_sid);
CREATE INDEX sip_realm_idx ON accounts (sip_realm); CREATE INDEX sip_realm_idx ON accounts (sip_realm);
CREATE INDEX service_provider_sid_idx ON accounts (service_provider_sid); CREATE INDEX service_provider_sid_idx ON accounts (service_provider_sid);
ALTER TABLE accounts ADD FOREIGN KEY service_provider_sid_idxfk_9 (service_provider_sid) REFERENCES service_providers (service_provider_sid); ALTER TABLE accounts ADD FOREIGN KEY service_provider_sid_idxfk_10 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
ALTER TABLE accounts ADD FOREIGN KEY registration_hook_sid_idxfk_1 (registration_hook_sid) REFERENCES webhooks (webhook_sid); ALTER TABLE accounts ADD FOREIGN KEY registration_hook_sid_idxfk_1 (registration_hook_sid) REFERENCES webhooks (webhook_sid);
@@ -572,4 +646,6 @@ ALTER TABLE accounts ADD FOREIGN KEY queue_event_hook_sid_idxfk (queue_event_hoo
ALTER TABLE accounts ADD FOREIGN KEY device_calling_application_sid_idxfk (device_calling_application_sid) REFERENCES applications (application_sid); ALTER TABLE accounts ADD FOREIGN KEY device_calling_application_sid_idxfk (device_calling_application_sid) REFERENCES applications (application_sid);
SET FOREIGN_KEY_CHECKS=0; ALTER TABLE accounts ADD FOREIGN KEY siprec_hook_sid_idxfk (siprec_hook_sid) REFERENCES applications (application_sid);
SET FOREIGN_KEY_CHECKS=1;

212
test/dial-tests.js Normal file
View File

@@ -0,0 +1,212 @@
const test = require('tape');
const { sippUac } = require('./sipp')('test_fs');
const bent = require('bent');
const getJSON = bent('json')
const clearModule = require('clear-module');
const {provisionCallHook} = require('./utils')
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
function connect(connectable) {
return new Promise((resolve, reject) => {
connectable.on('connect', () => {
return resolve();
});
});
}
test('\'dial-phone\'', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// wait for fs connected to drachtio server.
await new Promise(r => setTimeout(r, 1000));
// GIVEN
const from = "dial_success";
let verbs = [
{
"verb": "dial",
"callerId": from,
"actionHook": "/actionHook",
"timeLimit": 5,
"target": [
{
"type": "phone",
"number": "15083084809"
}
]
}
];
provisionCallHook(from, verbs);
// THEN
const p = sippUac('uas-dial.xml', '172.38.0.10', undefined, undefined, 2);
let account_sid = '622f62e4-303a-49f2-bbe0-eb1e1714e37a';
let post = bent('http://127.0.0.1:3000/', 'POST', 'json', 201);
post('v1/createCall', {
'account_sid':account_sid,
"call_hook": {
"url": "http://127.0.0.1:3100/",
"method": "POST",
},
"from": from,
"to": {
"type": "phone",
"number": "15583084808"
}});
await p;
obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
t.ok(obj.body.from === from,
'dial: succeeds actionHook');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'dial-sip\'', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// wait for fs connected to drachtio server.
await new Promise(r => setTimeout(r, 1000));
// GIVEN
const from = "dial_sip";
let verbs = [
{
"verb": "dial",
"callerId": from,
"actionHook": "/actionHook",
"dtmfCapture":["*2", "*3"],
"target": [
{
"type": "sip",
"sipUri": "sip:15083084809@jambonz.com"
}
]
}
];
provisionCallHook(from, verbs);
// THEN
const p = sippUac('uas-dial.xml', '172.38.0.10', undefined, undefined, 2);
let account_sid = '622f62e4-303a-49f2-bbe0-eb1e1714e37a';
let post = bent('http://127.0.0.1:3000/', 'POST', 'json', 201);
post('v1/createCall', {
'account_sid':account_sid,
"call_hook": {
"url": "http://127.0.0.1:3100/",
"method": "POST",
},
"from": from,
"to": {
"type": "phone",
"number": "15583084808"
}});
await new Promise(r => setTimeout(r, 2000));
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}`);
const callSid = obj.body.call_sid;
post = bent('http://127.0.0.1:3000/', 'POST', 202);
await post(`v1/updateCall/${callSid}`, {
"call_status": "completed"
});
await p;
obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
t.ok(obj.body.from === from,
'dial: succeeds actionHook');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'dial-user\'', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// wait for fs connected to drachtio server.
await new Promise(r => setTimeout(r, 1000));
// GIVEN
const from = "dial_user";
let verbs = [
{
"verb": "dial",
"callerId": from,
"actionHook": "/actionHook",
"target": [
{
"type": "user",
"name": "user110@jambonz.com"
}
]
}
];
provisionCallHook(from, verbs);
// THEN
const p = sippUac('uas-dial.xml', '172.38.0.10', undefined, undefined, 2);
let account_sid = '622f62e4-303a-49f2-bbe0-eb1e1714e37a';
let post = bent('http://127.0.0.1:3000/', 'POST', 'json', 201);
post('v1/createCall', {
'account_sid':account_sid,
"call_hook": {
"url": "http://127.0.0.1:3100/",
"method": "POST",
},
"from": from,
"to": {
"type": "phone",
"number": "15583084808"
}});
await new Promise(r => setTimeout(r, 2000));
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}`);
const callSid = obj.body.call_sid;
post = bent('http://127.0.0.1:3000/', 'POST', 202);
await post(`v1/updateCall/${callSid}`, {
"call_status": "completed"
});
await p;
obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
t.ok(obj.body.from === from,
'dial: succeeds actionHook');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});

View File

@@ -12,7 +12,7 @@ services:
platform: linux/x86_64 platform: linux/x86_64
ports: ports:
- "3360:3306" - "3360:3306"
environment: environment:
MYSQL_ALLOW_EMPTY_PASSWORD: "yes" MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
healthcheck: healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "127.0.0.1", "--protocol", "tcp"] test: ["CMD", "mysqladmin" ,"ping", "-h", "127.0.0.1", "--protocol", "tcp"]
@@ -44,7 +44,7 @@ services:
drachtio: drachtio:
image: drachtio/drachtio-server:latest image: drachtio/drachtio-server:latest
restart: always restart: always
command: drachtio --contact "sip:*;transport=udp,tcp" --address 0.0.0.0 --port 9022 command: drachtio --contact "sip:*;transport=udp" --mtu 4096 --address 0.0.0.0 --port 9022
ports: ports:
- "9060:9022/tcp" - "9060:9022/tcp"
networks: networks:
@@ -57,7 +57,7 @@ services:
condition: service_healthy condition: service_healthy
freeswitch: freeswitch:
image: drachtio/drachtio-freeswitch-mrf:v1.10.1-full image: drachtio/drachtio-freeswitch-mrf:0.4.18
restart: always restart: always
command: freeswitch --rtp-range-start 20000 --rtp-range-end 20100 command: freeswitch --rtp-range-start 20000 --rtp-range-end 20100
environment: environment:
@@ -68,17 +68,15 @@ services:
- /tmp:/tmp - /tmp:/tmp
- ./credentials:/opt/credentials - ./credentials:/opt/credentials
healthcheck: healthcheck:
test: ['CMD', 'fs_cli' ,'-x', '"sofia status"'] test: ['CMD', 'fs_cli' ,'-p', 'JambonzR0ck$$', '-x', '"sofia status"']
timeout: 5s timeout: 5s
retries: 15 retries: 15
networks: networks:
fs: fs:
ipv4_address: 172.38.0.51 ipv4_address: 172.38.0.51
webhook-decline: webhook-scaffold:
image: jambonz/webhook-test-scaffold:latest image: jambonz/webhook-test-scaffold:latest
environment:
APP_PATH: /tmp/decline.json
ports: ports:
- "3100:3000/tcp" - "3100:3000/tcp"
volumes: volumes:
@@ -87,54 +85,6 @@ services:
fs: fs:
ipv4_address: 172.38.0.60 ipv4_address: 172.38.0.60
webhook-say:
image: jambonz/webhook-test-scaffold:latest
environment:
APP_PATH: /tmp/say.json
ports:
- "3101:3000/tcp"
volumes:
- ./test-apps:/tmp
networks:
fs:
ipv4_address: 172.38.0.61
webhook-gather:
image: jambonz/webhook-test-scaffold:latest
environment:
APP_PATH: /tmp/gather.json
ports:
- "3102:3000/tcp"
volumes:
- ./test-apps:/tmp
networks:
fs:
ipv4_address: 172.38.0.62
webhook-transcribe:
image: jambonz/webhook-test-scaffold:latest
environment:
APP_PATH: /tmp/transcribe.json
ports:
- "3103:3000/tcp"
volumes:
- ./test-apps:/tmp
networks:
fs:
ipv4_address: 172.38.0.63
webhook-sip-info:
image: jambonz/webhook-test-scaffold:latest
environment:
APP_PATH: /tmp/info.json
ports:
- "3104:3000/tcp"
volumes:
- ./test-apps:/tmp
networks:
fs:
ipv4_address: 172.38.0.64
influxdb: influxdb:
image: influxdb:1.8 image: influxdb:1.8
ports: ports:

View File

@@ -3,6 +3,7 @@ const { sippUac } = require('./sipp')('test_fs');
const bent = require('bent'); const bent = require('bent');
const getJSON = bent('json') const getJSON = bent('json')
const clearModule = require('clear-module'); const clearModule = require('clear-module');
const {provisionCallHook} = require('./utils')
process.on('unhandledRejection', (reason, p) => { process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
@@ -16,16 +17,238 @@ function connect(connectable) {
}); });
} }
test('\'gather\' and \'transcribe\' tests', async(t) => { test('\'gather\' test - google', async(t) => {
if (!process.env.GCP_JSON_KEY) {
t.pass('skipping google tests');
return t.end();
}
clearModule.all(); clearModule.all();
const {srf, disconnect} = require('../app'); const {srf, disconnect} = require('../app');
try { try {
await connect(srf); await connect(srf);
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10'); // GIVEN
let obj = await getJSON('http://127.0.0.1:3102/actionHook'); let verbs = [
t.ok(obj.speech.alternatives[0].transcript = 'I\'d like to speak to customer support', {
'gather: succeeds when using account credentials'); "verb": "gather",
"input": ["speech"],
"recognizer": {
"vendor": "google",
"hints": ["customer support", "sales", "human resources", "HR"]
},
"timeout": 10,
"actionHook": "/actionHook"
}
];
let from = "gather_success";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
//console.log(JSON.stringify(obj));
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
'gather: succeeds when using google credentials');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'gather\' test - default (google)', async(t) => {
if (!process.env.GCP_JSON_KEY) {
t.pass('skipping google tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "gather",
"input": ["speech"],
"timeout": 10,
"actionHook": "/actionHook"
}
];
let from = "gather_success";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
//console.log(JSON.stringify(obj));
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase() === 'i\'d like to speak to customer support',
'gather: succeeds when using default (google) credentials');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'gather\' test - microsoft', async(t) => {
if (!process.env.MICROSOFT_REGION || !process.env.MICROSOFT_API_KEY) {
t.pass('skipping microsoft tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "gather",
"input": ["speech"],
"recognizer": {
"vendor": "microsoft",
"hints": ["customer support", "sales", "human resources", "HR"]
},
"timeout": 10,
"actionHook": "/actionHook"
}
];
let from = "gather_success";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
//console.log(JSON.stringify(obj));
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
'gather: succeeds when using microsoft credentials');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'gather\' test - aws', async(t) => {
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) {
t.pass('skipping aws tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "gather",
"input": ["speech"],
"recognizer": {
"vendor": "aws",
"hints": ["customer support", "sales", "human resources", "HR"]
},
"timeout": 10,
"actionHook": "/actionHook"
}
];
let from = "gather_success";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
//console.log(JSON.stringify(obj));
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
'gather: succeeds when using aws credentials');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'gather\' test - deepgram', async(t) => {
if (!process.env.DEEPGRAM_API_KEY ) {
t.pass('skipping deepgram tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "gather",
"input": ["speech"],
"recognizer": {
"vendor": "deepgram",
"hints": ["customer support", "sales", "human resources", "HR"],
"deepgramOptions": {
"apiKey": process.env.DEEPGRAM_API_KEY
}
},
"timeout": 10,
"actionHook": "/actionHook"
}
];
let from = "gather_success";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
//console.log(JSON.stringify(obj));
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
'gather: succeeds when using deepgram credentials');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'gather\' test - soniox', async(t) => {
if (!process.env.SONIOX_API_KEY ) {
t.pass('skipping soniox tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "gather",
"input": ["speech"],
"recognizer": {
"vendor": "deepgram",
"hints": ["customer support", "sales", "human resources", "HR"],
"deepgramOptions": {
"apiKey": process.env.SONIOX_API_KEY
}
},
"timeout": 10,
"actionHook": "/actionHook"
}
];
let from = "gather_success";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
console.log(JSON.stringify(obj));
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
'gather: succeeds when using soniox credentials');
disconnect(); disconnect();
} catch (err) { } catch (err) {

View File

@@ -1,10 +1,18 @@
require('./ws-requestor-unit-test');
require('./unit-tests'); require('./unit-tests');
require('./docker_start'); require('./docker_start');
require('./create-test-db'); require('./create-test-db');
require('./account-validation-tests'); require('./account-validation-tests');
require('./dial-tests');
require('./webhooks-tests'); require('./webhooks-tests');
require('./say-tests'); require('./say-tests');
require('./gather-tests'); require('./gather-tests');
require('./transcribe-tests');
require('./sip-request-tests'); require('./sip-request-tests');
require('./create-call-test');
require('./play-tests');
require('./sip-refer-tests');
require('./listen-tests');
require('./config-test');
require('./remove-test-db'); require('./remove-test-db');
require('./docker_stop'); require('./docker_stop');

149
test/listen-tests.js Normal file
View File

@@ -0,0 +1,149 @@
const test = require('tape');
const { sippUac } = require('./sipp')('test_fs');
const bent = require('bent');
const getJSON = bent('json')
const clearModule = require('clear-module');
const {provisionCallHook} = require('./utils')
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
function connect(connectable) {
return new Promise((resolve, reject) => {
connectable.on('connect', () => {
return resolve();
});
});
}
test('\'listen-success\'', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
const from = "listen_success";
let verbs = [
{
"verb": "listen",
"url": `ws://172.38.0.60:3000/${from}`,
"mixType" : "mono",
"actionHook": "/actionHook",
"playBeep": true,
}
];
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success-send-bye.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/ws_packet_count/${from}`);
t.ok(38000 <= obj.count, 'listen: success incoming call audio');
obj = await getJSON(`http://127.0.0.1:3100/ws_metadata/${from}`);
t.ok(obj.metadata.from === from && obj.metadata.sampleRate === 8000, 'listen: success metadata');
obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
t.ok(obj.body.from === from,
'listen: succeeds actionHook');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'listen-maxLength\'', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let from = "listen_timeout";
let verbs = [
{
"verb": "listen",
"url": `ws://172.38.0.60:3000/${from}`,
"mixType" : "mixed",
"timeout": 2,
"maxLength": 2
}
];
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/ws_packet_count/${from}`);
t.ok(30000 <= obj.count, 'listen: success maxLength incoming call audio');
obj = await getJSON(`http://127.0.0.1:3100/ws_metadata/${from}`);
t.ok(obj.metadata.from === from && obj.metadata.sampleRate === 8000, 'listen: success maxLength metadata');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'listen-pause-resume\'', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let from = "listen_timeout";
let verbs = [
{
"verb": "listen",
"url": `ws://172.38.0.60:3000/${from}`,
"mixType" : "mixed"
}
];
provisionCallHook(from, verbs);
// THEN
const p = sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
await new Promise(r => setTimeout(r, 2000));
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}`);
const callSid = obj.body.call_sid;
// GIVEN
// Pause listen
let post = bent('http://127.0.0.1:3000/', 'POST', 202);
await post(`v1/updateCall/${callSid}`, {
"listen_status": "pause"
});
await new Promise(r => setTimeout(r, 2000));
// Resume listen
post = bent('http://127.0.0.1:3000/', 'POST', 202);
await post(`v1/updateCall/${callSid}`, {
"listen_status": "resume"
});
// turn off the call
post = bent('http://127.0.0.1:3000/', 'POST', 202);
await post(`v1/updateCall/${callSid}`, {
"call_status": "completed"
});
await p;
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});

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

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

View File

@@ -1,6 +1,7 @@
const test = require('tape'); const test = require('tape');
const { sippUac } = require('./sipp')('test_fs'); const { sippUac } = require('./sipp')('test_fs');
const clearModule = require('clear-module'); const clearModule = require('clear-module');
const {provisionCallHook} = require('./utils')
process.on('unhandledRejection', (reason, p) => { process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
@@ -20,9 +21,21 @@ test('\'say\' tests', async(t) => {
try { try {
await connect(srf); 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'); t.pass('say: succeeds when using using account credentials');
disconnect(); disconnect();
} catch (err) { } catch (err) {
console.log(`error received: ${err}`); console.log(`error received: ${err}`);

View File

@@ -7,7 +7,7 @@
INVITE sip:16174000000@[remote_ip]:[remote_port] SIP/2.0 INVITE sip:16174000000@[remote_ip]:[remote_port] SIP/2.0
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch] 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]> To: <sip:16174000000@[remote_ip]:[remote_port]>
Call-ID: [call_id] Call-ID: [call_id]
CSeq: 1 INVITE CSeq: 1 INVITE
@@ -41,7 +41,7 @@
ACK sip:16174000000@[remote_ip]:[remote_port] SIP/2.0 ACK sip:16174000000@[remote_ip]:[remote_port] SIP/2.0
[last_Via] [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] To: <sip:16174000000@[remote_ip]:[remote_port]>[peer_tag_param]
Call-ID: [call_id] Call-ID: [call_id]
CSeq: 1 ACK CSeq: 1 ACK
@@ -53,4 +53,3 @@
</send> </send>
</scenario> </scenario>

View File

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

View File

@@ -8,13 +8,13 @@
<send retrans="500"> <send retrans="500">
<![CDATA[ <![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] 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:16174000003@[remote_ip]:[remote_port]> To: <sip:[to]@[remote_ip]:[remote_port]>
Call-ID: [call_id] Call-ID: [call_id]
CSeq: 1 INVITE CSeq: 1 INVITE
Contact: sip:sipp@[local_ip]:[local_port] Contact: sip:[from]@[local_ip]:[local_port]
Max-Forwards: 70 Max-Forwards: 70
X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f
Subject: uac-gather-account-creds-success Subject: uac-gather-account-creds-success
@@ -53,13 +53,13 @@
<send> <send>
<![CDATA[ <![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] 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: 16174000003 <sip:16174000003@[remote_ip]:[remote_port]>[peer_tag_param] To: [to] <sip:[to]@[remote_ip]:[remote_port]>[peer_tag_param]
Call-ID: [call_id] Call-ID: [call_id]
CSeq: 1 ACK CSeq: 1 ACK
Contact: sip:sipp@[local_ip]:[local_port] Contact: sip:[from]@[local_ip]:[local_port]
Max-Forwards: 70 Max-Forwards: 70
Subject: uac-gather-account-creds-success Subject: uac-gather-account-creds-success
Content-Length: 0 Content-Length: 0

View File

@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="ISO-8859-1" ?>
<!DOCTYPE scenario SYSTEM "sipp.dtd">
<scenario name="Basic Sipstone UAC">
<!-- In client mode (sipp placing calls), the Call-ID MUST be -->
<!-- generated by sipp. To do so, use [call_id] keyword. -->
<send retrans="500">
<![CDATA[
INVITE sip:[to]@[remote_ip]:[remote_port] SIP/2.0
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
To: <sip:[to]@[remote_ip]:[remote_port]>
Call-ID: [call_id]
CSeq: 1 INVITE
Contact: sip:[from]@[local_ip]:[local_port]
Max-Forwards: 70
X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f
Subject: uac-say
Content-Type: application/sdp
Content-Length: [len]
v=0
o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip]
s=-
c=IN IP[media_ip_type] [media_ip]
t=0 0
m=audio [media_port] RTP/AVP 0
a=rtpmap:0 PCMU/8000
]]>
</send>
<recv response="100"
optional="true">
</recv>
<recv response="180" optional="true">
</recv>
<recv response="183" rtd="true">
<action>
<ereg regexp=";branch=[^;]*" search_in="hdr" header="Via" check_it="false" assign_to="1"/>
</action>
</recv>
<send>
<![CDATA[
CANCEL sip:[to]@[remote_ip]:[remote_port] SIP/2.0
Via: SIP/2.0/[transport] [local_ip]:[local_port][$1]
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
To: [to] <sip:[to]@[remote_ip]:[remote_port]>
Call-ID: [call_id]
CSeq: [cseq] CANCEL
Contact: sip:[from]@[local_ip]:[local_port]
Max-Forwards: 70
]]>
</send>
<recv response="200" rtd="true">
</recv>
<recv response="487" rtd="true">
</recv>
<!-- Packet lost can be simulated in any send/recv message by -->
<!-- by adding the 'lost = "10"'. Value can be [1-100] percent. -->
<send>
<![CDATA[
ACK sip:[to]@[remote_ip]:[remote_port] SIP/2.0
Via: SIP/2.0/[transport] [local_ip]:[local_port][$1]
From: [from] <sip:[from]@[local_ip]:[local_port]>;tag=[pid]SIPpTag00[call_number]
To: [to] <sip:[to]@[remote_ip]:[remote_port]>[peer_tag_param]
Call-ID: [call_id]
CSeq: 1 ACK
Contact: sip:[from]@[local_ip]:[local_port]
Max-Forwards: 70
Subject: uac-say
Content-Length: 0
]]>
</send>
</scenario>

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

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

View File

@@ -8,13 +8,13 @@
<send retrans="500"> <send retrans="500">
<![CDATA[ <![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] 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:16174000001@[remote_ip]:[remote_port]> To: <sip:[to]@[remote_ip]:[remote_port]>
Call-ID: [call_id] Call-ID: [call_id]
CSeq: 1 INVITE CSeq: 1 INVITE
Contact: sip:sipp@[local_ip]:[local_port] Contact: sip:[from]@[local_ip]:[local_port]
Max-Forwards: 70 Max-Forwards: 70
X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f X-Account-Sid: bb845d4b-83a9-4cde-a6e9-50f3743bab3f
Subject: uac-say Subject: uac-say
@@ -53,13 +53,13 @@
<send> <send>
<![CDATA[ <![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] 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: 16174000001 <sip:16174000001@[remote_ip]:[remote_port]>[peer_tag_param] To: [to] <sip:[to]@[remote_ip]:[remote_port]>[peer_tag_param]
Call-ID: [call_id] Call-ID: [call_id]
CSeq: 1 ACK CSeq: 1 ACK
Contact: sip:sipp@[local_ip]:[local_port] Contact: sip:[from]@[local_ip]:[local_port]
Max-Forwards: 70 Max-Forwards: 70
Subject: uac-say Subject: uac-say
Content-Length: 0 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>

164
test/scenarios/uas-dial.xml Normal file
View File

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

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

View File

@@ -3,6 +3,7 @@ const { sippUac } = require('./sipp')('test_fs');
const bent = require('bent'); const bent = require('bent');
const getJSON = bent('json') const getJSON = bent('json')
const clearModule = require('clear-module'); const clearModule = require('clear-module');
const {provisionCallHook} = require('./utils')
process.on('unhandledRejection', (reason, p) => { process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
@@ -22,9 +23,28 @@ test('sending SIP in-dialog requests tests', async(t) => {
try { try {
await connect(srf); await connect(srf);
await sippUac('uac-send-info-during-dialog.xml', '172.38.0.10'); //GIVEN
const obj = await getJSON('http://127.0.0.1:3104/actionHook'); let verbs = [
t.ok(obj.result === 'success' && obj.sip_status === 200, 'successfully sent SIP INFO'); {
"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(); disconnect();
} catch (err) { } catch (err) {

View File

@@ -53,6 +53,13 @@ test('incoming call tests', (t) => {
.then(() => { .then(() => {
return t.pass('handles in-dialog requests'); 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(() => { .then(() => {
srf.disconnect(); srf.disconnect();
t.end(); t.end();

View File

@@ -24,22 +24,24 @@ obj.output = () => {
return output; return output;
}; };
obj.sippUac = (file, bindAddress) => { obj.sippUac = (file, bindAddress, from='sipp', to='16174000000', loop=1) => {
const cmd = 'docker'; const cmd = 'docker';
const args = [ const args = [
'run', '-t', '--rm', '--net', `${network}`, 'run', '-t', '--rm', '--net', `${network}`,
'-v', `${__dirname}/scenarios:/tmp/scenarios`, '-v', `${__dirname}/scenarios:/tmp/scenarios`,
'drachtio/sipp', 'sipp', '-sf', `/tmp/scenarios/${file}`, 'drachtio/sipp', 'sipp', '-sf', `/tmp/scenarios/${file}`,
'-m', '1', '-m', loop,
'-sleep', '250ms', '-sleep', '250ms',
'-nostdin', '-nostdin',
'-cid_str', `%u-%p@%s-${idx++}`, '-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); if (bindAddress) args.splice(5, 0, '--ip', bindAddress);
console.log(args.join(' ')); //console.log(args.join(' '));
clearOutput(); clearOutput();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -61,7 +63,7 @@ obj.sippUac = (file, bindAddress) => {
addOutput(data.toString()); addOutput(data.toString());
}); });
child_process.stdout.on('data', (data) => { child_process.stdout.on('data', (data) => {
//console.log(`stdout: ${data}`); // console.log(`stdout: ${data}`);
addOutput(data.toString()); addOutput(data.toString());
}); });
}); });

210
test/transcribe-tests.js Normal file
View File

@@ -0,0 +1,210 @@
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('\'transcribe\' test - google', async(t) => {
if (!process.env.GCP_JSON_KEY) {
t.pass('skipping google tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "transcribe",
"recognizer": {
"vendor": "google",
"hints": ["customer support", "sales", "human resources", "HR"]
},
"transcriptionHook": "/transcriptionHook"
}
];
let from = "gather_success";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
'transcribe: succeeds when using google credentials');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'transcribe\' test - microsoft', async(t) => {
if (!process.env.MICROSOFT_REGION || !process.env.MICROSOFT_API_KEY) {
t.pass('skipping microsoft tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "transcribe",
"recognizer": {
"vendor": "microsoft",
"hints": ["customer support", "sales", "human resources", "HR"]
},
"transcriptionHook": "/transcriptionHook"
}
];
let from = "gather_success";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
'transcribe: succeeds when using microsoft credentials');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'transcribe\' test - aws', async(t) => {
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) {
t.pass('skipping aws tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "transcribe",
"recognizer": {
"vendor": "aws",
"hints": ["customer support", "sales", "human resources", "HR"]
},
"transcriptionHook": "/transcriptionHook"
}
];
let from = "gather_success";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
'transcribe: succeeds when using aws credentials');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'transcribe\' test - deepgram', async(t) => {
if (!process.env.DEEPGRAM_API_KEY ) {
t.pass('skipping deepgram tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "transcribe",
"recognizer": {
"vendor": "deepgram",
"hints": ["customer support", "sales", "human resources", "HR"],
"deepgramOptions": {
"apiKey": process.env.DEEPGRAM_API_KEY
}
},
"transcriptionHook": "/transcriptionHook"
}
];
let from = "gather_success";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
'transcribe: succeeds when using deepgram credentials');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'transcribe\' test - soniox', async(t) => {
if (!process.env.SONIOX_API_KEY ) {
t.pass('skipping soniox tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "transcribe",
"recognizer": {
"vendor": "soniox",
"hints": ["customer support", "sales", "human resources", "HR"],
"deepgramOptions": {
"apiKey": process.env.SONIOX_API_KEY
}
},
"transcriptionHook": "/transcriptionHook"
}
];
let from = "gather_success";
provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
console.log(JSON.stringify(obj));
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
'transcribe: succeeds when using soniox credentials');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});

View File

@@ -44,10 +44,25 @@ test('unit tests', (t) => {
task = makeTask(logger, require('./data/good/say-text-array')); task = makeTask(logger, require('./data/good/say-text-array'));
t.ok(task.name === 'say', 'parsed say with multiple segments'); t.ok(task.name === 'say', 'parsed say with multiple segments');
task = makeTask(logger, require('./data/good/say-ssml'));
// the ssml is more than 1000 chars,
// expecting first chunk is length > 100, stop at ? instead of first .
// 2nd chunk is long text < 1000 char, stop at .
// 3rd chunk is the rest.
t.ok(task.text.length === 3 &&
task.text[0].length === 187 &&
task.text[1].length === 882 &&
task.text[2].length === 123, 'parsed say');
task = makeTask(logger, require('./data/bad/bad-say-ssml'));
t.ok(task.text.length === 1 &&
task.text[0].length === 1162, 'parsed bad say');
const alt = require('./data/good/alternate-syntax'); const alt = require('./data/good/alternate-syntax');
const normalize = require('../lib/utils/normalize-jambones'); const { normalizeJambones } = require('@jambonz/verb-specifications');
normalize(logger, alt).forEach((t) => { normalizeJambones(logger, alt).forEach((t) => {
const task = makeTask(logger, t); const task = makeTask(logger, t);
}); });
t.pass('alternate syntax works'); t.pass('alternate syntax works');
@@ -62,4 +77,4 @@ const errMissingProperty = () => makeTask(logger, require('./data/bad/missing-re
const errInvalidType = () => makeTask(logger, require('./data/bad/invalid-type')); const errInvalidType = () => makeTask(logger, require('./data/bad/invalid-type'));
const errBadEnum = () => makeTask(logger, require('./data/bad/bad-enum')); const errBadEnum = () => makeTask(logger, require('./data/bad/bad-enum'));
const errBadPayload = () => makeTask(logger, require('./data/bad/bad-payload')); const errBadPayload = () => makeTask(logger, require('./data/bad/bad-payload'));
const errBadPayload2 = () => makeTask(logger, require('./data/bad/bad-payload2')); const errBadPayload2 = () => makeTask(logger, require('./data/bad/bad-payload2'));

36
test/utils.js Normal file
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 FROM --platform=linux/amd64 node:18.6.0-alpine as base
RUN apk update && apk add --no-cache python make g++
WORKDIR /opt/app/
COPY package.json ./
RUN npm install
RUN npm prune
FROM node:alpine as webapp RUN apk --update --no-cache add --virtual .builds-deps build-base python3
RUN apk add curl
WORKDIR /opt/app WORKDIR /opt/app/
COPY . /opt/app
COPY --from=builder /opt/app/node_modules ./node_modules FROM base as build
COPY ./entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh COPY package.json package-lock.json ./
ENTRYPOINT ["/entrypoint.sh"]
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

@@ -1,49 +1,190 @@
const assert = require('assert');
const fs = require('fs');
const express = require('express'); const express = require('express');
const app = express(); const app = express();
const Websocket = require('ws');
const listenPort = process.env.HTTP_PORT || 3000; const listenPort = process.env.HTTP_PORT || 3000;
let lastAction, lastEvent; let json_mapping = new Map();
let hook_mapping = new Map();
let ws_packet_count = new Map();
let ws_metadata = new Map();
assert.ok(process.env.APP_PATH, 'env var APP_PATH is required'); /** websocket server for listen audio */
const recvAudio = (socket, req) => {
let packets = 0;
let path = req.url;
console.log('received websocket connection');
socket.on('message', (data, isBinary) => {
if (!isBinary) {
try {
const msg = JSON.parse(data);
console.log({msg}, 'received websocket message');
ws_metadata.set(path, msg);
}
catch (err) {
console.log({err}, 'error parsing websocket message');
}
}
else {
packets += data.length;
}
});
socket.on('error', (err) => {
console.log({err}, 'listen websocket: error');
});
app.listen(listenPort, () => { socket.on('close', () => {
ws_packet_count.set(path, packets);
})
};
const wsServer = new Websocket.Server({ noServer: true });
wsServer.setMaxListeners(0);
wsServer.on('connection', recvAudio.bind(null));
const server = app.listen(listenPort, () => {
console.log(`sample jambones app server listening on ${listenPort}`); console.log(`sample jambones app server listening on ${listenPort}`);
}); });
server.on('upgrade', (request, socket, head) => {
console.log('received upgrade request');
wsServer.handleUpgrade(request, socket, head, (socket) => {
wsServer.emit('connection', socket, request);
});
});
const applicationData = JSON.parse(fs.readFileSync(process.env.APP_PATH));
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
app.use(express.json()); app.use(express.json());
/*
* Markup language
*/
app.all('/', (req, res) => { app.all('/', (req, res) => {
console.log(applicationData, `${req.method} /`); console.log(req.body, 'POST /');
return res.json(applicationData); const key = req.body.from
addRequestToMap(key, req, hook_mapping);
return getJsonFromMap(key, req, res);
}); });
app.post('/appMapping', (req, res) => {
console.log(req.body, 'POST /appMapping');
json_mapping.set(req.body.from, req.body.data);
return res.sendStatus(200);
});
/*
* Status Callback
*/
app.post('/callStatus', (req, res) => { app.post('/callStatus', (req, res) => {
console.log({payload: req.body}, 'POST /callStatus'); console.log({payload: req.body}, 'POST /callStatus');
let key = req.body.from + "_callStatus"
addRequestToMap(key, req, hook_mapping);
return res.sendStatus(200); return res.sendStatus(200);
}); });
/*
* transcriptionHook
*/
app.post('/transcriptionHook', (req, res) => {
console.log({payload: req.body}, 'POST /transcriptionHook');
let key = req.body.from + "_actionHook"
addRequestToMap(key, req, hook_mapping);
return res.json([{"verb": "hangup"}]);
});
/*
* actionHook
*/
app.post('/actionHook', (req, res) => { app.post('/actionHook', (req, res) => {
console.log({payload: req.body}, 'POST /actionHook'); 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); return res.sendStatus(200);
}); });
app.get('/actionHook', (req, res) => { /*
console.log({payload: lastAction}, 'GET /actionHook'); * customHook
return res.json(lastAction); * 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) => { app.post('/customHookMapping', (req, res) => {
console.log({payload: req.body}, 'POST /eventHook'); let key = `${req.body.from}_customHook`;
lastEvent = req.body; console.log(req.body, `POST /customHookMapping`);
json_mapping.set(key, req.body.data);
return res.sendStatus(200); return res.sendStatus(200);
}); });
app.get('/eventHook', (req, res) => { // Fetch Requests
console.log({payload: lastEvent}, 'GET /eventHook'); app.get('/requests/:key', (req, res) => {
return res.json(lastEvent); let key = req.params.key;
}); if (hook_mapping.has(key)) {
return res.json(hook_mapping.get(key));
} else {
return res.sendStatus(404);
}
})
app.get('/lastRequest/:key', (req, res) => {
let key = req.params.key;
if (hook_mapping.has(key)) {
let requests = hook_mapping.get(key);
return res.json(requests[requests.length - 1]);
} else {
return res.sendStatus(404);
}
})
// WS Fetch
app.get('/ws_packet_count/:key', (req, res) => {
let key = `/${req.params.key}`;
console.log(key, ws_packet_count);
if (ws_packet_count.has(key)) {
return res.json({ count: ws_packet_count.get(key) });
} else {
return res.sendStatus(404);
}
})
app.get('/ws_metadata/:key', (req, res) => {
let key = `/${req.params.key}`;
console.log(key, ws_packet_count);
if (ws_metadata.has(key)) {
return res.json({ metadata: ws_metadata.get(key) });
} else {
return res.sendStatus(404);
}
})
/*
* private function
*/
function getJsonFromMap(key, req, res) {
if (!json_mapping.has(key)) return res.sendStatus(404);
const retData = JSON.parse(json_mapping.get(key));
console.log(retData, ` Response to ${req.method} ${req.url}`);
addRequestToMap(key, req, hook_mapping);
return res.json(retData);
}
function addRequestToMap(key, req, map) {
let headers = new Map()
for(let i = 0; i < req.rawHeaders.length; i++) {
if (i % 2 === 0) {
headers.set(req.rawHeaders[i], req.rawHeaders[i + 1])
}
}
let request = {
'url': req.url,
'headers': Object.fromEntries(headers),
'body': req.body
}
if (map.has(key)) {
map.get(key).push(request);
} else {
map.set(key, [request]);
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@
"author": "Dave Horton", "author": "Dave Horton",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"express": "^4.17.1" "express": "^4.18.2",
"ws": "^8.12.0"
} }
} }

View File

@@ -1,6 +1,15 @@
const test = require('tape'); const test = require('tape');
const { sippUac } = require('./sipp')('test_fs'); const { sippUac } = require('./sipp')('test_fs');
const clearModule = require('clear-module'); 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) => { process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
@@ -20,7 +29,21 @@ test('basic webhook tests', async(t) => {
try { try {
await connect(srf); 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'); t.pass('webhook successfully declines call');
disconnect(); disconnect();
@@ -30,3 +53,43 @@ test('basic webhook tests', async(t) => {
t.error(err); 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));
}

97
test/ws-mock.js Normal file
View File

@@ -0,0 +1,97 @@
class MockWebsocket {
static eventResponses = new Map();
static actionLoops = new Map();
eventListeners = new Map();
constructor(url, protocols, options) {
this.u = url;
this.pros = protocols;
this.opts = options;
setTimeout(() => {
this.open();
}, 500)
}
static addJsonMapping(key, value) {
MockWebsocket.eventResponses.set(key, value);
}
static getAndIncreaseActionLoops(key) {
const ret = MockWebsocket.actionLoops.has(key) ? MockWebsocket.actionLoops.get(key) : 0;
MockWebsocket.actionLoops.set(key, ret + 1);
return ret;
}
once(event, listener) {
// Websocket.ws = this;
this.eventListeners.set(event, listener);
return this;
}
on(event, listener) {
// Websocket.ws = this;
this.eventListeners.set(event, listener);
return this;
}
open() {
if (this.eventListeners.has('open')) {
this.eventListeners.get('open')();
}
}
removeAllListeners() {
this.eventListeners.clear();
}
send(data, callback) {
const json = JSON.parse(data);
console.log({json}, 'got message from ws-requestor');
if (MockWebsocket.eventResponses.has(json.call_sid)) {
const resp_data = MockWebsocket.eventResponses.get(json.call_sid);
const action = resp_data.action[MockWebsocket.getAndIncreaseActionLoops(json.call_sid)];
if (action === 'connect') {
setTimeout(()=> {
const msg = {
type: 'ack',
msgid: json.msgid,
command: 'command',
call_sid: json.call_sid,
queueCommand: false,
data: resp_data.body}
console.log({msg}, 'sending ack to ws-requestor');
this.mockOnMessage(JSON.stringify(msg));
}, 100);
} else if (action === 'close') {
if (this.eventListeners.has('close')) {
this.eventListeners.get('close')(500);
}
} else if (action === 'terminate') {
if (this.eventListeners.has('close')) {
this.eventListeners.get('close')(1000);
}
} else if (action === 'error') {
if (this.eventListeners.has('error')) {
this.eventListeners.get('error')();
}
} else if (action === 'unexpected-response') {
if (this.eventListeners.has('unexpected-response')) {
this.eventListeners.get('unexpected-response')();
}
}
}
if (callback) {
callback();
}
}
mockOnMessage(message, isBinary=false) {
if (this.eventListeners.has('message')) {
this.eventListeners.get('message')(message, isBinary);
}
}
}
module.exports = MockWebsocket;

View File

@@ -0,0 +1,198 @@
const test = require('tape');
const sinon = require('sinon');
const proxyquire = require("proxyquire");
proxyquire.noCallThru();
const MockWebsocket = require('./ws-mock')
const logger = require('pino')({level: process.env.JAMBONES_LOGLEVEL || 'error'});
const BaseRequestor = proxyquire(
"../lib/utils/base-requestor",
{
"../../": {
srf: {
locals: {
stats: {
histogram: () => {}
}
}
}
},
"@jambonz/time-series": sinon.stub()
}
);
const WsRequestor = proxyquire(
"../lib/utils/ws-requestor",
{
"./base-requestor": BaseRequestor,
"ws": MockWebsocket
}
);
test('ws success', async (t) => {
// GIVEN
const json = '[{\"verb\": \"play\",\"url\": \"silence_stream://5000\"}]';
const ws_response = {
action: ['connect'],
body: json
}
const call_sid = 'ws_success';
MockWebsocket.addJsonMapping(call_sid, ws_response);
const hook = {
url: 'ws://localhost:3000',
username: 'username',
password: 'password'
}
const params = {
callSid: call_sid
}
// WHEN
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
const result = await requestor.request('session:new',hook, params, {});
// THEN
t.ok(result == json,'ws successfully sent session:new and got initial jambonz app');
t.end();
});
test('ws close success reconnect', async (t) => {
// GIVEN
const call_sid = 'ws_closed'
const json = '[{\"verb\": \"play\",\"url\": \"silence_stream://5000\"}]';
const ws_response = {
action: ['close', 'connect'],
body: json
}
MockWebsocket.addJsonMapping(call_sid, ws_response);
const hook = {
url: 'ws://localhost:3000',
username: 'username',
password: 'password'
}
const params = {
callSid: call_sid
}
// WHEN
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
const result = await requestor.request('session:new',hook, params, {});
// THEN
t.ok(result == json,'ws successfully reconnect after close from far end');
t.end();
});
test('ws response error 1000', async (t) => {
// GIVEN
const call_sid = 'ws_terminated'
const json = '[{\"verb\": \"play\",\"url\": \"silence_stream://5000\"}]';
const ws_response = {
action: ['terminate'],
body: json
}
MockWebsocket.addJsonMapping(call_sid, ws_response);
const hook = {
url: 'ws://localhost:3000',
username: 'username',
password: 'password'
}
const params = {
callSid: call_sid
}
// WHEN
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
try {
await requestor.request('session:new',hook, params, {});
}
catch (err) {
// THEN
t.ok(err.startsWith('timeout from far end for msgid'), 'ws does not reconnect if far end closes gracefully');
t.end();
}
});
test('ws response error', async (t) => {
// GIVEN
const call_sid = 'ws_error'
const json = '[{\"verb\": \"play\",\"url\": \"silence_stream://5000\"}]';
const ws_response = {
action: ['error'],
body: json
}
MockWebsocket.addJsonMapping(call_sid, ws_response);
const hook = {
url: 'ws://localhost:3000',
username: 'username',
password: 'password'
}
const params = {
callSid: call_sid
}
// WHEN
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
try {
await requestor.request('session:new',hook, params, {});
}
catch (err) {
// THEN
t.ok(err.startsWith('timeout from far end for msgid'), 'ws does not reconnect if far end closes gracefully');
t.end();
}
});
test('ws unexpected-response', async (t) => {
// GIVEN
const call_sid = 'ws_unexpected-response'
const json = '[{\"verb\": \"play\",\"url\": \"silence_stream://5000\"}]';
const ws_response = {
action: ['unexpected-response'],
body: json
}
MockWebsocket.addJsonMapping(call_sid, ws_response);
const hook = {
url: 'ws://localhost:3000',
username: 'username',
password: 'password'
}
const params = {
callSid: call_sid
}
// WHEN
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
try {
await requestor.request('session:new',hook, params, {});
}
catch (err) {
// THEN
t.ok(err.code = 'ERR_ASSERTION', 'ws does not reconnect if far end closes gracefully');
t.end();
}
});

View File

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