Compare commits

..

167 Commits

Author SHA1 Message Date
Hoan Luu Huu
2285ec5329 fix transcribe credential (#480) 2023-10-10 21:06:46 -04:00
Hoan Luu Huu
09ae083c9a fix: transcribe 2 channels (#479)
* fix: transcribe 2 channels

* fix

* fix
2023-10-10 08:46:50 -04:00
Hoan Luu Huu
6a3e12e293 feat support refer teluri (#476)
* feat support refer teluri

* update drachtio source code
2023-10-10 08:42:52 -04:00
Hoan Luu Huu
48f2c57ae2 feat sip indialog actionHook (#477)
* feat sip indialog actionHook

* feat sip indialog actionHook

* feat sip indialog actionHook

* feat sip indialog actionHook

* update verb spec

* fix

* fix

* rename function as required _onRequestWithinDialog
2023-10-10 08:41:32 -04:00
Dave Horton
f651cfa0b7 prune unused function (#478) 2023-10-09 15:03:27 -04:00
Dave Horton
cb78627e66 tag is allowed in siprec (#475) 2023-10-06 08:53:13 -04:00
Hoan Luu Huu
ae9386791f fix cobalt model (#469) 2023-09-26 07:41:43 -04:00
Dave Horton
1aa0b07b8f fix: deepgram redact option 2023-09-22 08:13:54 -04:00
Hoan Luu Huu
4e916acf6c fixed clean dualEp for rest dial dual_stream=true (#468)
* fixed clean dualEp for rest dial dual_stream=true

* fix

* fix

* fix

* fix

* fix
2023-09-22 08:12:34 -04:00
Hoan Luu Huu
991fff3386 dual streams (#467)
* dual streams

* dual streams

* dual streams

* dual streams

* use sdp transform
2023-09-21 07:57:30 -04:00
Dave Horton
76cf4e527f minor: allow hints not to be specified 2023-09-18 10:32:07 -04:00
Dave Horton
d7affddd85 minor fix to create-call: pass cs param to restDial.kill task 2023-09-14 09:10:24 -04:00
Anton Voylenko
d42798e0b4 validate recording env (#466) 2023-09-14 07:40:32 -04:00
Dave Horton
6a8a2aa955 update to provide hostport and model on the command line not via env (#465) 2023-09-13 13:53:00 -04:00
Dave Horton
6587b1f758 include fallback speech selections, if any, in the initial webhook (#464) 2023-09-13 10:19:37 -04:00
Hoan Luu Huu
c29def92e8 feat fast recognition (#461)
* feat fast recognition

* don't use buffer transcript for fast recognition

* update verb specification

* fix merge conflict

---------

Co-authored-by: Dave Horton <daveh@beachdognet.com>
2023-09-13 09:59:12 -04:00
Dave Horton
a1793ac359 Speech vendor/cobalt (#463)
* initial changes for cobalt speech

* wip

* wip

* update to drachtio-fsmrf that supports cobalt

* update to verb-specifications with cobalt speech support

* more wip

* lint

* use node 18 with gh actions

* support for compiling cobalt hints

* fix bug in uuid_cobalt_compile_context

* update verb-specifications

* remove repeated code

* cobalt support for transcribe

* update to verb specs
2023-09-13 09:47:30 -04:00
Hoan Luu Huu
d220733dea add JAMBONZ_DISABLE_DIAL_PAI_HEADER env var (#460) 2023-09-08 20:02:20 -04:00
Hoan Luu Huu
a09605fc51 update speech util version (#458) 2023-09-08 09:58:09 -04:00
Paulo Telles
7f59bba634 issue #456 error on validating hints using microsoft (#454)
* error ono validating hints using microsoft

* fix lint

* fix lint 2

* remove try catch

---------

Co-authored-by: p.souza <p.souza@cognigy.com>
2023-09-08 09:21:44 -04:00
Hoan Luu Huu
1477605e66 fix custom stt transcribe (#457) 2023-09-08 08:09:03 -04:00
Dave Horton
4f0ab83f5f fix #445 (#452) 2023-08-31 08:03:45 -04:00
Hoan Luu Huu
2935574440 feat pause resume transcribe (#438)
* feat pause resume transcribe

* wip

* fix jslint

* update fsmrf
2023-08-30 22:43:50 -04:00
Hoan Luu Huu
c10c561ba1 update speech util 0.0.20 (#446) 2023-08-30 22:43:08 -04:00
Hoan Luu Huu
2ccd33e212 feat azure fromHost (#416)
* feat azure fromHost

* wip

* wip

* wip
2023-08-30 21:04:53 -04:00
Hoan Luu Huu
a03baa8461 update serviceUrl to the success port (#439)
* update serviceUrl to the success port

* correct log
2023-08-30 09:23:35 -04:00
Hoan Luu Huu
90df33a15c fix choose speech dedential by label (#441)
* fix choose speech dedential by label

* wip
2023-08-30 09:22:33 -04:00
two56
a15479e6dc parseInt JAMBONZ_MYSQL_REFRESH_TTL (#443)
Co-authored-by: Matt Preskett <matt.preskett@netcall.com>
2023-08-30 07:27:05 -04:00
Hoan Luu Huu
dd74cb2cc6 update vulnerable xml2j version (#440) 2023-08-28 18:58:11 -04:00
Hoan Luu Huu
7a02c36bad fix missing speech vendor in telemetry metric (#437) 2023-08-28 07:45:00 -04:00
Dave Horton
78fd4549af minor logging 2023-08-22 13:15:06 -04:00
Hoan Luu Huu
b1ecf069bf Feat/fallback speech 02 (#429)
* feat fallback speech

* feat fallback speech

* feat fallback speech

* gather verb

* fix

* wip

* wip

* wip

* wip

* wip

* transcribe

* transcribe

* fix

* wip

* wip

* wip

* wip

* fix

* wip

* fix
2023-08-22 09:22:01 -04:00
Hoan Luu Huu
6f0dbef433 feat moh (#423)
* feat moh

* feat moh

* fix typo

* fix typo

* fix

* fix

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* git commit -a -m wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* fix

* fix

* fix

* fix

* wip

* rebase

* fix

* fix
2023-08-22 08:09:18 -04:00
two56
32dcb2adfa Cancel the transaction instead of killing the task (#431)
Co-authored-by: Matt Preskett <matt.preskett@netcall.com>
Co-authored-by: Hoan Luu Huu <110280845+xquanluu@users.noreply.github.com>
2023-08-21 07:46:53 -04:00
Dave Horton
ee514f7459 update references from jambonz.us to jambonz.cloud (#433) 2023-08-19 14:42:35 -04:00
Anton Voylenko
4cfea0707a fix participant join options (#432)
* fix participant join options

* bump verb specifications
2023-08-19 14:18:13 -04:00
Hoan Luu Huu
f8c5abe9e9 feat: multi speech credential diff labels but same vendor (#426)
* feat: multi speech credential diff labels but same vendor

* update sql

* fix

* fix

* fix jslint

* fix review comment

* update verb spec version
2023-08-15 08:57:49 -04:00
Dave Horton
ad722a55ee generate trace id before outdial so we can include it in custom header (#418)
* generate trace id before outdial so we can include it in custom header

* logging

* logging

* fix #420 race condition on rest outdial when ws is used

* revert unnecessary logging change
2023-08-08 13:00:34 -04:00
Hoan Luu Huu
82939214a2 update stats-collector version (#421) 2023-08-07 21:22:10 -04:00
Dave Horton
043a171f41 remove log message 2023-08-07 15:22:03 -04:00
Dave Horton
c8e9b34b53 fix typo that caused record to fail on rest calls 2023-08-07 14:46:51 -04:00
Hoan Luu Huu
d7dcdb1d0c Continuos ASR for transcribe (#398)
* asrTimeout

* fix jslint

* change log

* fix interrim
2023-08-03 09:49:44 -04:00
Dave Horton
fbd0782258 #388 - support custom speech vendor in transcribe verb (#414)
Co-authored-by: Hoan Luu Huu <110280845+xquanluu@users.noreply.github.com>
2023-08-02 19:06:31 -04:00
Fábio Gomes
38f9329b12 When recordings are enabled, disable bidirectional audio on jambonz-session-record (#415) 2023-08-02 14:21:59 -04:00
Dave Horton
d4bfdf0916 #412 - dont delay sending call status when stopping background listen (#413) 2023-08-02 12:50:13 -04:00
Dave Horton
9203deef0f fix bug in prev commit 2023-08-02 10:27:50 -04:00
Dave Horton
48b182c891 Fix/rest outdial failure session hangs (#411)
* fix #410

* on rest outdial failure, if remote end closed gracefully don't wait for a reconnection
2023-08-01 12:59:30 -04:00
Dave Horton
e8e987cb9d Fix/snake case customer data issue 406 (#409)
* revert recent change on silence trimming

* fix issue with incorrectly snake-casing customer data (#406)
2023-07-27 22:31:43 -04:00
Dave Horton
38ea9e7411 update to speech-utils@0.0.18 which ignores trimming of silence on azure ssml audio 2023-07-25 07:51:46 -04:00
Hoan Luu Huu
7b11a56a53 feat siprec custom header (#400)
* feat siprec custom header

* wip

* update verb specification

* add newline to info siprec body

* add newline to info siprec body
2023-07-20 09:10:41 -04:00
Dave Horton
66305b5aea feature: optionally trim silence from azure tts (#399) 2023-07-19 10:36:24 -04:00
Dave Horton
6793bbf330 fix exception that appears in logs if session ends before last call status update 2023-07-18 13:20:53 -04:00
Hoan Luu Huu
d8543f73f2 execute status callback async (#394)
* execute status callback async

* fix review comment

* revert fix review comment
2023-07-18 12:40:57 -04:00
Hoan Luu Huu
e1dad569dc Fix/background listen tag (#391)
* fix background listen send customerData to api server

* test listen

* fix review comment
2023-07-11 16:03:32 +01:00
Hoan Luu Huu
643bee48c5 feat multi srs (#381) 2023-07-05 08:16:59 +01:00
Dave Horton
487bfd90d9 0.8.4 2023-06-28 09:23:40 +01:00
Hoan Luu Huu
810f6eb695 fix aws-sdk v3 (#387)
* fix aws-sdk v3

* fix jslint

* fix jslint

* fix aws response parser
2023-06-28 09:20:43 +01:00
Hoan Luu Huu
62bc6b4bac feat: add fs service url to sbc ping option (#383)
* feat multi srs

* add fs service URL to SBC ping option
2023-06-23 11:13:08 +01:00
two56
91fe3ceb06 Clear conference details in both Jambonz and FreeSWITCH (#350)
Co-authored-by: Matt Preskett <matt.preskett@netcall.com>
2023-06-14 15:35:04 -04:00
Dave Horton
a7d07ce7ae add channel to transcribe, gather, and dtmf spans (#376) 2023-06-13 09:12:26 -04:00
Dave Horton
7cd6c27f90 update deps (#375)
* update deps

* added fixes
2023-06-09 15:40:50 -04:00
Hoan Luu Huu
aad24744f3 feat: record all calls (#352)
* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* fix jslint

* fix

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* fix: add file ext

* fix: record format

* fix outbound

* update to drachtio-fsmrf with support for multiple recording streams on a call

* enable DTMF during background listen recording

* fix merge commit

---------

Co-authored-by: Dave Horton <daveh@beachdognet.com>
2023-06-09 14:54:53 -04:00
Dave Horton
ab0452879e add X-Application-Sid in outdials so it ends up in cdr (#374) 2023-06-09 12:57:35 -04:00
Dave Horton
ffdb7a0bb5 create transcribe and listen child spans properly for dial (#373)
* create transcribe and listen child spans properly for dial

* fix prev commit: proper time to start span is in dial exec
2023-06-08 13:57:10 -04:00
Hoan Luu Huu
354818b974 feat: sentinel configuraiton (#372)
* feat: sentinel configuraiton

* fixed

* fix jslint
2023-06-07 10:40:31 -04:00
Dave Horton
30beb9c093 transcribe: default hints and altLanguages (#371) 2023-06-06 13:41:31 -04:00
Dave Horton
b978b3bc2f add optional ws ping (#370) 2023-06-05 11:00:14 -04:00
Hoan Luu Huu
a1c38f8a2e fix: queue length in account event hook (#369) 2023-06-05 08:41:10 -04:00
Dave Horton
37f3668016 update to speech-utils 0.0.15 2023-06-03 09:19:26 -04:00
Dave Horton
55935e3f35 skip flaky test 2023-06-03 09:09:49 -04:00
Hoan Luu Huu
b7070121ee feat: advanced queues (#362)
* feat: advanced queues

* feat: advanced queues

* feat: advanced queues

* feat: advanced queues

* update verb specification

* add testcase

* add testcase

* add testcase

* updte testcase

* fixed

* update queue

* fix: fix waithook params

* fix: fix waithook params

* fix: performQueueWebhook with correct members length

* fix merge conflict

* debug log

* debug listen test

* debug listen test

* debug listen test

* debug listen test

* debug listen test

* debug listen issue

* feat: add tts on account level

---------

Co-authored-by: Dave Horton <daveh@beachdognet.com>
2023-06-03 08:16:05 -04:00
Dave Horton
01260ad054 transcribe: create otel child spans for each stt result that is returned in a long-running transcribe (#368) 2023-06-02 14:25:32 -04:00
Dave Horton
bd911c88f9 in verb transcribe, recognizer should be optional (default to application default) but transcriptionHook mudt be mandatory (#367) 2023-06-02 13:15:32 -04:00
Hoan Luu Huu
d96712a8d6 feat: add tts on account level (#366) 2023-06-02 09:31:28 -04:00
Dave Horton
fdd8f7e743 Fix/logger reference (#365)
* fix logger reference

* fix undefined logger reference
2023-06-01 09:46:08 -04:00
Hoan Luu Huu
bb852600c0 fix: app_json is used for outbound call (#358)
* fix: app_json is used for outbound call

* fix jslint

* fix: app_json setter in rest:dial task
2023-06-01 08:52:53 -04:00
Quan HL
210bbcbdbf forward inbound carrier sid to outbound dial 2023-05-29 09:10:33 -04:00
Dave Horton
5910dbf0d3 fix for carrier selection on dial based on calling number 2023-05-26 12:27:51 -04:00
Dave Horton
90468ffe48 handle missing callerId property for anonymous calls 2023-05-25 13:28:42 -04:00
Dave Horton
863c4dfa34 fix docker publish build gh action 2023-05-25 13:19:46 -04:00
Dave Horton
484be8442c fix for #359 - selection of outbound carrier based on calling number 2023-05-25 13:19:46 -04:00
Dave Horton
7393e3bcb7 standardize on passing .query args as array (#356) 2023-05-22 09:56:05 -04:00
Hoan Luu Huu
32a84b7b19 feat: rest:dial amd (#339)
Add support for sending 'amd' property in createCall REST API and also added support for using any of the speech vendors for STT
---------

Co-authored-by: Dave Horton <daveh@beachdognet.com>
2023-05-16 16:20:08 -04:00
Dave Horton
6933e82d46 fix docker build 2023-05-15 14:04:29 -04:00
Dave Horton
fb1801ce11 0.8.3 2023-05-11 10:51:08 -04:00
Dave Horton
09abb23968 minor test change 2023-05-11 10:50:56 -04:00
two56
eb1e0d3bf5 Fix: REST dial timeout (#351)
* Fix #343 by cancelling the request if the session isn't available

* Commit that works for 302's calls

---------

Co-authored-by: Matt Preskett <matt.preskett@netcall.com>
2023-05-11 10:17:39 -04:00
Hoan Luu Huu
3b6c103618 feat: update db-helper and speech-utils (#347) 2023-05-10 08:04:56 -04:00
Dave Horton
feccc0fca7 add support for azure custom voices on a per-say basis (#346) 2023-05-09 13:25:43 -04:00
Hoan Luu Huu
51bcb5a2d2 fix: rivauri (#345)
* fix: rivauri

* fix: rivauri
2023-05-09 10:27:15 -04:00
Dave Horton
7a184a8bbc Fix/tracing cleanup (#342)
* tracing usability

* fix bug in prev commit

* more cleanup

* further tracing UI cleanup
2023-05-08 14:35:07 -04:00
Dave Horton
5043edfd4e addresses #340 and #331 (#341) 2023-05-08 12:23:32 -04:00
Hoan Luu Huu
9948592080 feat: update verb specification version (#330) 2023-04-28 08:44:10 -04:00
Dave Horton
6dc019e836 fix: amd support for language other than en-US (#322) 2023-04-19 15:12:51 -04:00
Dave Horton
a22bc8ea42 fix issue where multiple gathers running simultaneously (#321) 2023-04-18 21:58:30 -04:00
Hoan Luu Huu
0356b996ba fix: wss requestor incase mysql cache is used (#319) 2023-04-18 06:34:39 -04:00
Dave Horton
271587617e update verb specifications 2023-04-13 13:28:21 -04:00
Dave Horton
0b29e67a0c better logging of ws commands 2023-04-13 13:26:52 -04:00
EgleH
e656d275fe update mase image to node:18.15-alpine3.16 (#316) 2023-04-12 16:18:54 -04:00
Hoan Luu Huu
fabf01f8b5 feat: callerName to rest_dial and dial verb (#312)
* feat: callerName to rest_dial and dial verb

* update verb specification
2023-04-12 10:04:55 -04:00
Dave Horton
85ab75d8e3 cherry-pick commit from pr 313 2023-04-12 08:15:32 -04:00
Dave Horton
5c2630fe1f webapp-scaffold can not reference code in higher level app (#314) 2023-04-12 07:54:21 -04:00
Dave Horton
9942313ea1 update drachtio-fsmrf to fix bug in prev commit 2023-04-11 22:55:19 -04:00
Dave Horton
e67cb18b6d fix bug from prev commit 2023-04-11 16:31:53 -04:00
Markus Frindt
86df53f8c4 Feature/centralized configs (#310)
* [snyk] fix vulnerabilities

* move all process.env in one config

* update log level in config

* check envs

* fix imports in tests for microsoft, soniox, deepgram

* fix import in gather-test

* fix missing imports

---------

Co-authored-by: Markus Frindt <m.frindt@cognigy.com>
2023-04-11 12:46:52 -04:00
Vinod Dharashive
5d50f68725 Handling siprec caller and callee null (#308)
* NBR Support

* NBR Support

* NBR Support

* re-invite Ok sdp should be sendonly

* NBR Support

* sendrecv sdp correction

* Update siprec-utils.js

* Updated comments

* Siprec participants details added to hook

* Bugfix siprec

* Update call-info.js

* Handling siprec caller and callee null
2023-04-10 12:54:26 -04:00
Shailendra Paliwal
f22b236dfc createCall: add a default behavior if the trunk isn't defined (#230)
* add methods to lookupTrunkbyPhone

* change the object name

* fix typo in readme

* export method with return

* add checks to dial verb

* sans extra spaces

* change the variable name for lookup
2023-04-10 10:15:05 -04:00
Hoan Luu Huu
2862c827e0 feat: upload docker image to docker hub (#307)
* feat: upload docker image to docker hub

---------

Co-authored-by: Quan HL <quan.luuhoan8@gmail.com>
Co-authored-by: Dave Horton <daveh@beachdognet.com>
2023-04-10 09:39:20 -04:00
Dave Horton
266980d770 add support for anchoring media on dial verb (#304) 2023-04-10 08:25:12 -04:00
Snyk bot
04003a709e fix: package.json & package-lock.json to reduce vulnerabilities (#305)
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-XML2JS-5414874
2023-04-09 12:41:47 -04:00
Dave Horton
565ee609ef based on more testing default google to command_and_search for gather and latest_long for transcribe 2023-04-07 07:34:03 -04:00
Vinod Dharashive
9587465e85 Support for Cisco NBR for Agentassist (#303)
* NBR Support

* NBR Support

* NBR Support

* re-invite Ok sdp should be sendonly

* NBR Support

* sendrecv sdp correction

* Update siprec-utils.js

* Updated comments

* Siprec participants details added to hook

* Bugfix siprec

* Update call-info.js
2023-04-07 07:33:24 -04:00
Dave Horton
845d80a23d change population of test data 2023-04-05 13:10:01 -04:00
Hoan Luu Huu
3109db7861 feat: update stat collector version (#302) 2023-04-05 12:02:41 -04:00
Hoan Luu Huu
11c5047465 fix: Re-invite sip rec does not update media (#300)
* fix: Re-invite sip rec does not update media

* fix: Re-invite sip rec does not update media
2023-04-05 09:46:32 -04:00
Dave Horton
e19ea629f0 response to siprec invite should have a:recvonly if offer had a:sendonly (#298) 2023-04-04 21:02:21 -04:00
Antony Jukes
fe529c6bfb removed incorrect "this" from this.target.auth as it actually a local const. (#296) 2023-04-03 11:13:12 -04:00
Dave Horton
e980b82ec4 update to speech utils with improved microsoft tts 2023-04-01 13:20:59 -04:00
Hoan Luu Huu
318ca19791 fix: update speech utils version (#295)
* fix: update speech utils version

* update package-lock.json

---------

Co-authored-by: Dave Horton <daveh@beachdognet.com>
2023-04-01 11:35:13 -04:00
Dave Horton
e2bd211346 update to latest speech-utils 2023-03-31 16:50:46 -04:00
Dave Horton
410c07fae6 further fix for google model 2023-03-31 12:37:04 -04:00
Dave Horton
2ebfbfb3d8 google STT: when altLanguges are used default to a model that supports it 2023-03-31 12:31:14 -04:00
Dave Horton
a29795839d Bugfix/bot mode restart (#292)
* restart background gather if we get a new config with bargein=enable and changes to input types

* stop background gather properly before restarting

* fix: sticky background gather tasks must not be restarted if we have a new background gather

* fix undefined reference

* safety
2023-03-31 09:35:23 -04:00
Hoan Luu Huu
28088a4cdd feat: queue play audio (#290)
* feat: queue play audio

* fix: typo

* fix: typo

* fix: typo

* fix: typo

* fix: typo

* fix: typo

* fix: typo

* fix: typo

* fix: typo

* fix: typo
2023-03-30 15:31:54 -04:00
Dave Horton
afb381eec9 bugfix: setting altLanguages on Azure once left it turned on 2023-03-29 08:49:34 -04:00
Dave Horton
ed00ccb681 bump version 2023-03-28 14:14:25 -04:00
Dave Horton
6e945dde9a google stt fixes, including defaulting to phone_call model based on c… (#288)
* google stt fixes, including defaulting to phone_call model based on comparative model testing

* lint error
2023-03-28 10:02:03 -04:00
Dave Horton
efdea3e514 gather defaults to multiple utterances 2023-03-27 15:53:01 -04:00
Dave Horton
5131d524ce bugfix: allow for empty transcripts that nuance returns 2023-03-27 14:13:50 -04:00
Anton Voylenko
c0114015ea check encryption env on start (#286) 2023-03-26 15:45:20 -04:00
Anton Voylenko
a293ec09d0 add ENCRYPTION_SECRET variable (#283)
* add ENCRYPTION_SECRET variable

* add env for tests
2023-03-26 14:52:58 -04:00
Dave Horton
f71ae83ce4 bugfix: nuance on-prem stt 2023-03-26 14:26:36 -04:00
Hoan Luu Huu
0dd161913c fix: gather task should clear dtmf event before resolve (#284)
Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
2023-03-26 12:32:51 -04:00
Dave Horton
63ab554908 google STT: default to command_and_search for Gather, as latest_short seems to have issues, various other fixes (#285) 2023-03-26 12:20:03 -04:00
Dave Horton
e1bd075ebc support for nuance on-prem stt/tts 2023-03-25 12:08:54 -04:00
Dave Horton
9de89258a1 update speech-utils@0.0.8 2023-03-24 14:50:08 -04:00
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
76 changed files with 11532 additions and 2806 deletions

View File

@@ -9,7 +9,7 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: 16 node-version: 18
- run: npm ci - run: npm ci
- run: npm run jslint - run: npm run jslint
- run: docker pull drachtio/sipp - run: docker pull drachtio/sipp

View File

@@ -2,16 +2,10 @@ name: Docker
on: on:
push: push:
# Publish `main` as Docker `latest` image.
branches: branches:
- main - main
# Publish `v1.2.3` tags as releases.
tags: tags:
- v* - '*'
env:
IMAGE_NAME: feature-server
jobs: jobs:
push: push:
@@ -20,32 +14,41 @@ jobs:
if: github.event_name == 'push' if: github.event_name == 'push'
steps: steps:
- uses: actions/checkout@v3 - name: Checkout code
uses: actions/checkout@v3
- name: Build image - name: prepare tag
run: docker build . --file Dockerfile --tag $IMAGE_NAME id: prepare_tag
- name: Log into registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Push image
run: | run: |
IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME IMAGE_ID=jambonz/feature-server
# Change all uppercase to lowercase # Strip git ref prefix from version
IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
# Strip git ref prefix from version # Strip "v" prefix from tag name
VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')
# Strip "v" prefix from tag name # Use Docker `latest` tag convention
[[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') [ "$VERSION" == "main" ] && VERSION=latest
# Use Docker `latest` tag convention echo IMAGE_ID=$IMAGE_ID
[ "$VERSION" == "main" ] && VERSION=latest echo VERSION=$VERSION
echo IMAGE_ID=$IMAGE_ID echo "image_id=$IMAGE_ID" >> $GITHUB_OUTPUT
echo VERSION=$VERSION echo "version=$VERSION" >> $GITHUB_OUTPUT
docker tag $IMAGE_NAME $IMAGE_ID:$VERSION - name: Login to Docker Hub
docker push $IMAGE_ID:$VERSION uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: ${{ steps.prepare_tag.outputs.image_id }}:${{ steps.prepare_tag.outputs.version }}
build-args: |
GITHUB_REPOSITORY=$GITHUB_REPOSITORY
GITHUB_REF=$GITHUB_REF

View File

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

View File

@@ -18,8 +18,10 @@ Configuration is provided via environment variables:
|DRACHTIO_PORT| listening port of drachtio server for control connections (typically 9022)|yes| |DRACHTIO_PORT| listening port of drachtio server for control connections (typically 9022)|yes|
|DRACHTIO_SECRET| shared secret|yes| |DRACHTIO_SECRET| shared secret|yes|
|ENABLE_METRICS| if 1, metrics will be generated|no| |ENABLE_METRICS| if 1, metrics will be generated|no|
|ENCRYPTION_SECRET| secret for credential encryption(JWT_SECRET is deprecated) |yes|
|GOOGLE_APPLICATION_CREDENTIALS| path to gcp service key file|yes| |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|
@@ -35,6 +37,9 @@ Configuration is provided via environment variables:
|STATS_PORT| listening port for metrics host|no| |STATS_PORT| listening port for metrics host|no|
|STATS_PROTOCOL| 'tcp' or 'udp'|no| |STATS_PROTOCOL| 'tcp' or 'udp'|no|
|STATS_TELEGRAF| if 1, metrics will be generated in telegraf format|no| |STATS_TELEGRAF| if 1, metrics will be generated in telegraf format|no|
|JAMBONZ_RECORD_WS_BASE_URL| recording websocket URL to send the recording audio|no|
|JAMBONZ_RECORD_WS_USERNAME| recording websocket username|no|
|JAMBONZ_RECORD_WS_PASSWORD| recording websocket password|no|
### running under pm2 ### running under pm2
Typically, this application runs under [pm2](https://pm2.io) using an [ecosystem.config.js](https://pm2.keymetrics.io/docs/usage/application-declaration/) file similar to this: Typically, this application runs under [pm2](https://pm2.io) using an [ecosystem.config.js](https://pm2.keymetrics.io/docs/usage/application-declaration/) file similar to this:
@@ -87,4 +92,4 @@ module.exports = {
#### Running the test suite #### Running the test suite
Please [see this]](./docs/contributing.md#run-the-regression-test-suite). Please [see this](./docs/contributing.md#run-the-regression-test-suite).

54
app.js
View File

@@ -1,21 +1,28 @@
const assert = require('assert'); const {
assert.ok(process.env.JAMBONES_MYSQL_HOST && DRACHTIO_PORT,
process.env.JAMBONES_MYSQL_USER && DRACHTIO_HOST,
process.env.JAMBONES_MYSQL_PASSWORD && DRACHTIO_SECRET,
process.env.JAMBONES_MYSQL_DATABASE, 'missing JAMBONES_MYSQL_XXX env vars'); JAMBONES_OTEL_SERVICE_NAME,
assert.ok(process.env.DRACHTIO_PORT || process.env.DRACHTIO_HOST, 'missing DRACHTIO_PORT env var'); JAMBONES_LOGLEVEL,
assert.ok(process.env.DRACHTIO_SECRET, 'missing DRACHTIO_SECRET env var'); JAMBONES_CLUSTER_ID,
assert.ok(process.env.JAMBONES_FREESWITCH, 'missing JAMBONES_FREESWITCH env var'); JAMBONZ_CLEANUP_INTERVAL_MINS,
assert.ok(process.env.JAMBONES_REDIS_HOST, 'missing JAMBONES_REDIS_HOST env var'); getCleanupIntervalMins,
assert.ok(process.env.JAMBONES_NETWORK_CIDR || process.env.K8S, 'missing JAMBONES_SUBNET env var'); K8S,
NODE_ENV,
checkEnvs,
} = require('./lib/config');
checkEnvs();
const Srf = require('drachtio-srf'); const Srf = require('drachtio-srf');
const srf = new Srf(); const srf = new Srf();
const tracer = require('./tracer')(process.env.JAMBONES_OTEL_SERVICE_NAME || 'jambonz-feature-server'); const tracer = require('./tracer')(JAMBONES_OTEL_SERVICE_NAME);
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 opts = {level: process.env.JAMBONES_LOGLEVEL || 'info'}; const opts = {
level: JAMBONES_LOGLEVEL
};
const pino = require('pino'); const pino = require('pino');
const logger = pino(opts, pino.destination({sync: false})); const logger = pino(opts, pino.destination({sync: false}));
const {LifeCycleEvents, FS_UUID_SET_NAME} = require('./lib/utils/constants'); const {LifeCycleEvents, FS_UUID_SET_NAME} = require('./lib/utils/constants');
@@ -35,8 +42,8 @@ const {
const InboundCallSession = require('./lib/session/inbound-call-session'); const InboundCallSession = require('./lib/session/inbound-call-session');
const SipRecCallSession = require('./lib/session/siprec-call-session'); const SipRecCallSession = require('./lib/session/siprec-call-session');
if (process.env.DRACHTIO_HOST) { if (DRACHTIO_HOST) {
srf.connect({host: process.env.DRACHTIO_HOST, port: process.env.DRACHTIO_PORT, secret: process.env.DRACHTIO_SECRET }); srf.connect({host: DRACHTIO_HOST, port: DRACHTIO_PORT, secret: DRACHTIO_SECRET });
srf.on('connect', (err, hp) => { srf.on('connect', (err, hp) => {
const arr = /^(.*)\/(.*)$/.exec(hp.split(',').pop()); const arr = /^(.*)\/(.*)$/.exec(hp.split(',').pop());
srf.locals.localSipAddress = `${arr[2]}`; srf.locals.localSipAddress = `${arr[2]}`;
@@ -44,10 +51,10 @@ if (process.env.DRACHTIO_HOST) {
}); });
} }
else { else {
logger.info(`listening for drachtio requests on port ${process.env.DRACHTIO_PORT}`); logger.info(`listening for drachtio requests on port ${DRACHTIO_PORT}`);
srf.listen({port: process.env.DRACHTIO_PORT, secret: process.env.DRACHTIO_SECRET}); srf.listen({port: DRACHTIO_PORT, secret: DRACHTIO_SECRET});
} }
if (process.env.NODE_ENV === 'test') { if (NODE_ENV === 'test') {
srf.on('error', (err) => { srf.on('error', (err) => {
logger.info(err, 'Error connecting to drachtio'); logger.info(err, 'Error connecting to drachtio');
}); });
@@ -112,13 +119,18 @@ function handle(signal) {
const {removeFromSet} = srf.locals.dbHelpers; const {removeFromSet} = srf.locals.dbHelpers;
srf.locals.disabled = true; srf.locals.disabled = true;
logger.info(`got signal ${signal}`); logger.info(`got signal ${signal}`);
const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-fs`; const setName = `${(JAMBONES_CLUSTER_ID || 'default')}:active-fs`;
const fsServiceUrlSetName = `${(JAMBONES_CLUSTER_ID || 'default')}:fs-service-url`;
if (setName && srf.locals.localSipAddress) { if (setName && srf.locals.localSipAddress) {
logger.info(`got signal ${signal}, removing ${srf.locals.localSipAddress} from set ${setName}`); logger.info(`got signal ${signal}, removing ${srf.locals.localSipAddress} from set ${setName}`);
removeFromSet(setName, srf.locals.localSipAddress); removeFromSet(setName, srf.locals.localSipAddress);
} }
if (fsServiceUrlSetName && srf.locals.serviceUrl) {
logger.info(`got signal ${signal}, removing ${srf.locals.serviceUrl} from set ${fsServiceUrlSetName}`);
removeFromSet(fsServiceUrlSetName, srf.locals.serviceUrl);
}
removeFromSet(FS_UUID_SET_NAME, srf.locals.fsUUID); removeFromSet(FS_UUID_SET_NAME, srf.locals.fsUUID);
if (process.env.K8S) { if (K8S) {
srf.locals.lifecycleEmitter.operationalState = LifeCycleEvents.ScaleIn; srf.locals.lifecycleEmitter.operationalState = LifeCycleEvents.ScaleIn;
} }
if (getCount() === 0) { if (getCount() === 0) {
@@ -127,7 +139,7 @@ function handle(signal) {
} }
} }
if (process.env.JAMBONZ_CLEANUP_INTERVAL_MINS) { if (JAMBONZ_CLEANUP_INTERVAL_MINS) {
const {clearFiles} = require('./lib/utils/cron-jobs'); const {clearFiles} = require('./lib/utils/cron-jobs');
/* cleanup orphaned files or channels every so often */ /* cleanup orphaned files or channels every so often */
@@ -137,7 +149,7 @@ if (process.env.JAMBONZ_CLEANUP_INTERVAL_MINS) {
} catch (err) { } catch (err) {
logger.error({err}, 'app.js: error clearing files'); logger.error({err}, 'app.js: error clearing files');
} }
}, 1000 * 60 * (process.env.JAMBONZ_CLEANUP_INTERVAL_MINS || 60)); }, getCleanupIntervalMins());
} }
module.exports = {srf, logger, disconnect}; module.exports = {srf, logger, disconnect};

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
const bent = require('bent'); const bent = require('bent');
const getJSON = bent('json'); const getJSON = bent('json');
const PORT = process.env.HTTP_PORT || 3000; const {PORT} = require('../lib/config')
const sleep = (ms) => { const sleep = (ms) => {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));

View File

@@ -5,14 +5,11 @@
"at the tone", "at the tone",
"leave a message", "leave a message",
"leave me a message", "leave me a message",
"not available right now", "not available",
"not available to take your call",
"can't take your call", "can't take your call",
"I will get back to you", "will get back to you",
"I'll get back to you", "I'll get back to you",
"we will get back to you", "we are unable"
"we are unable",
"we are not available"
], ],
"es-ES": [ "es-ES": [
"le pasamos la llamada", "le pasamos la llamada",
@@ -48,5 +45,18 @@
"ens posarem en contacto", "ens posarem en contacto",
"ara no estem disponibles", "ara no estem disponibles",
"no hi som" "no hi som"
],
"de-DE": [
"nicht erreichbar",
"nnruf wurde weitergeleitet",
"beim piepsen",
"am ton",
"eine nachricht hinterlassen",
"hinterlasse mir eine Nachricht",
"nicht verfügbar",
"kann ihren anruf nicht entgegennehmen",
"wird sich bei Ihnen melden",
"ich melde mich bei dir",
"wir können nicht"
] ]
} }

231
lib/config.js Normal file
View File

@@ -0,0 +1,231 @@
const assert = require('assert');
const checkEnvs = () => {
assert.ok(process.env.JAMBONES_MYSQL_HOST &&
process.env.JAMBONES_MYSQL_USER &&
process.env.JAMBONES_MYSQL_PASSWORD &&
process.env.JAMBONES_MYSQL_DATABASE, 'missing JAMBONES_MYSQL_XXX env vars');
assert.ok(process.env.DRACHTIO_PORT || process.env.DRACHTIO_HOST, 'missing DRACHTIO_PORT env var');
assert.ok(process.env.DRACHTIO_SECRET, 'missing DRACHTIO_SECRET env var');
assert.ok(process.env.JAMBONES_FREESWITCH, 'missing JAMBONES_FREESWITCH env var');
if (process.env.JAMBONES_REDIS_SENTINELS) {
assert.ok(process.env.JAMBONES_REDIS_SENTINEL_MASTER_NAME,
'missing JAMBONES_REDIS_SENTINEL_MASTER_NAME env var, JAMBONES_REDIS_SENTINEL_PASSWORD env var is optional');
} else {
assert.ok(process.env.JAMBONES_REDIS_HOST, 'missing JAMBONES_REDIS_HOST env var');
}
assert.ok(process.env.JAMBONES_NETWORK_CIDR || process.env.K8S, 'missing JAMBONES_SUBNET env var');
};
const NODE_ENV = process.env.NODE_ENV;
/* database mySQL */
const JAMBONES_MYSQL_HOST = process.env.JAMBONES_MYSQL_HOST;
const JAMBONES_MYSQL_USER = process.env.JAMBONES_MYSQL_USER;
const JAMBONES_MYSQL_PASSWORD = process.env.JAMBONES_MYSQL_PASSWORD;
const JAMBONES_MYSQL_DATABASE = process.env.JAMBONES_MYSQL_DATABASE;
const JAMBONES_MYSQL_PORT = parseInt(process.env.JAMBONES_MYSQL_PORT, 10) || 3306;
const JAMBONES_MYSQL_REFRESH_TTL = parseInt(process.env.JAMBONES_MYSQL_REFRESH_TTL, 10) || 0;
const JAMBONES_MYSQL_CONNECTION_LIMIT = parseInt(process.env.JAMBONES_MYSQL_CONNECTION_LIMIT, 10) || 10;
/* redis */
const JAMBONES_REDIS_HOST = process.env.JAMBONES_REDIS_HOST;
const JAMBONES_REDIS_PORT = parseInt(process.env.JAMBONES_REDIS_PORT, 10) || 6379;
/* gather and hints */
const JAMBONES_GATHER_EARLY_HINTS_MATCH = process.env.JAMBONES_GATHER_EARLY_HINTS_MATCH;
const JAMBONZ_GATHER_EARLY_HINTS_MATCH = process.env.JAMBONZ_GATHER_EARLY_HINTS_MATCH;
const JAMBONES_GATHER_CLEAR_GLOBAL_HINTS_ON_EMPTY_HINTS = process.env.JAMBONES_GATHER_CLEAR_GLOBAL_HINTS_ON_EMPTY_HINTS;
const SMPP_URL = process.env.SMPP_URL;
/* drachtio */
const DRACHTIO_PORT = process.env.DRACHTIO_PORT;
const DRACHTIO_HOST = process.env.DRACHTIO_HOST;
const DRACHTIO_SECRET = process.env.DRACHTIO_SECRET;
/* freeswitch */
const JAMBONES_API_BASE_URL = process.env.JAMBONES_API_BASE_URL;
const JAMBONES_FREESWITCH = process.env.JAMBONES_FREESWITCH;
const JAMBONES_FREESWITCH_MAX_CALL_DURATION_MINS = parseInt(process.env.JAMBONES_FREESWITCH_MAX_CALL_DURATION_MINS, 10)
|| 180;
const JAMBONES_SBCS = process.env.JAMBONES_SBCS;
/* websockets */
const JAMBONES_WS_HANDSHAKE_TIMEOUT_MS = parseInt(process.env.JAMBONES_WS_HANDSHAKE_TIMEOUT_MS, 10) || 1500;
const JAMBONES_WS_MAX_PAYLOAD = parseInt(process.env.JAMBONES_WS_MAX_PAYLOAD, 10) || 24 * 1024;
const JAMBONES_WS_PING_INTERVAL_MS = parseInt(process.env.JAMBONES_WS_PING_INTERVAL_MS, 10) || 0;
const MAX_RECONNECTS = 5;
const RESPONSE_TIMEOUT_MS = parseInt(process.env.JAMBONES_WS_API_MSG_RESPONSE_TIMEOUT, 10) || 5000;
const JAMBONES_NETWORK_CIDR = process.env.JAMBONES_NETWORK_CIDR;
const JAMBONES_TIME_SERIES_HOST = process.env.JAMBONES_TIME_SERIES_HOST;
const JAMBONES_CLUSTER_ID = process.env.JAMBONES_CLUSTER_ID || 'default';
const JAMBONES_ESL_LISTEN_ADDRESS = process.env.JAMBONES_ESL_LISTEN_ADDRESS;
/* tracing */
const JAMBONES_OTEL_ENABLED = process.env.JAMBONES_OTEL_ENABLED;
const JAMBONES_OTEL_SERVICE_NAME = process.env.JAMBONES_OTEL_SERVICE_NAME || 'jambonz-feature-server';
const OTEL_EXPORTER_JAEGER_AGENT_HOST = process.env.OTEL_EXPORTER_JAEGER_AGENT_HOST;
const OTEL_EXPORTER_JAEGER_ENDPOINT = process.env.OTEL_EXPORTER_JAEGER_ENDPOINT;
const OTEL_EXPORTER_ZIPKIN_URL = process.env.OTEL_EXPORTER_ZIPKIN_URL;
const OTEL_EXPORTER_COLLECTOR_URL = process.env.OTEL_EXPORTER_COLLECTOR_URL;
const JAMBONES_LOGLEVEL = process.env.JAMBONES_LOGLEVEL || 'info';
const JAMBONES_INJECT_CONTENT = process.env.JAMBONES_INJECT_CONTENT;
const PORT = parseInt(process.env.HTTP_PORT, 10) || 3000;
const HTTP_PORT_MAX = parseInt(process.env.HTTP_PORT_MAX, 10);
const K8S = process.env.K8S;
const K8S_SBC_SIP_SERVICE_NAME = process.env.K8S_SBC_SIP_SERVICE_NAME;
const JAMBONES_SUBNET = process.env.JAMBONES_SUBNET;
/* clean up */
const JAMBONZ_CLEANUP_INTERVAL_MINS = process.env.JAMBONZ_CLEANUP_INTERVAL_MINS;
const getCleanupIntervalMins = () => {
const interval = parseInt(JAMBONZ_CLEANUP_INTERVAL_MINS, 10) || 60;
return 1000 * 60 * interval;
};
/* speech vendors */
const AWS_REGION = process.env.AWS_REGION;
const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID;
const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY;
const AWS_SNS_PORT = parseInt(process.env.AWS_SNS_PORT, 10) || 3001;
const AWS_SNS_TOPIC_ARM = process.env.AWS_SNS_TOPIC_ARM;
const AWS_SNS_PORT_MAX = parseInt(process.env.AWS_SNS_PORT_MAX, 10) || 3005;
const GCP_JSON_KEY = process.env.GCP_JSON_KEY;
const MICROSOFT_REGION = process.env.MICROSOFT_REGION;
const MICROSOFT_API_KEY = process.env.MICROSOFT_API_KEY;
const SONIOX_API_KEY = process.env.SONIOX_API_KEY;
const DEEPGRAM_API_KEY = process.env.DEEPGRAM_API_KEY;
const ANCHOR_MEDIA_ALWAYS = process.env.ANCHOR_MEDIA_ALWAYS;
const VMD_HINTS_FILE = process.env.VMD_HINTS_FILE;
/* security, secrets */
const LEGACY_CRYPTO = !!process.env.LEGACY_CRYPTO;
const JWT_SECRET = process.env.JWT_SECRET;
const ENCRYPTION_SECRET = process.env.ENCRYPTION_SECRET;
/* HTTP/1 pool dispatcher */
const HTTP_POOL = process.env.HTTP_POOL && parseInt(process.env.HTTP_POOL);
const HTTP_POOLSIZE = parseInt(process.env.HTTP_POOLSIZE, 10) || 10;
const HTTP_PIPELINING = parseInt(process.env.HTTP_PIPELINING, 10) || 1;
const HTTP_TIMEOUT = 10000;
const OPTIONS_PING_INTERVAL = parseInt(process.env.OPTIONS_PING_INTERVAL, 10) || 30000;
const JAMBONES_REDIS_SENTINELS = process.env.JAMBONES_REDIS_SENTINELS ? {
sentinels: process.env.JAMBONES_REDIS_SENTINELS.split(',').map((sentinel) => {
let host, port = 26379;
if (sentinel.includes(':')) {
const arr = sentinel.split(':');
host = arr[0];
port = parseInt(arr[1], 10);
} else {
host = sentinel;
}
return {host, port};
}),
name: process.env.JAMBONES_REDIS_SENTINEL_MASTER_NAME,
...(process.env.JAMBONES_REDIS_SENTINEL_PASSWORD && {
password: process.env.JAMBONES_REDIS_SENTINEL_PASSWORD
}),
...(process.env.JAMBONES_REDIS_SENTINEL_USERNAME && {
username: process.env.JAMBONES_REDIS_SENTINEL_USERNAME
})
} : null;
const JAMBONZ_RECORD_WS_BASE_URL = process.env.JAMBONZ_RECORD_WS_BASE_URL || process.env.JAMBONES_RECORD_WS_BASE_URL;
const JAMBONZ_RECORD_WS_USERNAME = process.env.JAMBONZ_RECORD_WS_USERNAME || process.env.JAMBONES_RECORD_WS_USERNAME;
const JAMBONZ_RECORD_WS_PASSWORD = process.env.JAMBONZ_RECORD_WS_PASSWORD || process.env.JAMBONES_RECORD_WS_PASSWORD;
const JAMBONZ_DISABLE_DIAL_PAI_HEADER = process.env.JAMBONZ_DISABLE_DIAL_PAI_HEADER || false;
module.exports = {
JAMBONES_MYSQL_HOST,
JAMBONES_MYSQL_USER,
JAMBONES_MYSQL_PASSWORD,
JAMBONES_MYSQL_DATABASE,
JAMBONES_MYSQL_REFRESH_TTL,
JAMBONES_MYSQL_CONNECTION_LIMIT,
JAMBONES_MYSQL_PORT,
DRACHTIO_PORT,
DRACHTIO_HOST,
DRACHTIO_SECRET,
JAMBONES_GATHER_EARLY_HINTS_MATCH,
JAMBONZ_GATHER_EARLY_HINTS_MATCH,
JAMBONES_GATHER_CLEAR_GLOBAL_HINTS_ON_EMPTY_HINTS,
JAMBONES_FREESWITCH,
JAMBONES_REDIS_HOST,
JAMBONES_REDIS_PORT,
JAMBONES_REDIS_SENTINELS,
SMPP_URL,
JAMBONES_NETWORK_CIDR,
JAMBONES_API_BASE_URL,
JAMBONES_TIME_SERIES_HOST,
JAMBONES_INJECT_CONTENT,
JAMBONES_ESL_LISTEN_ADDRESS,
JAMBONES_SBCS,
JAMBONES_OTEL_ENABLED,
JAMBONES_OTEL_SERVICE_NAME,
OTEL_EXPORTER_JAEGER_AGENT_HOST,
OTEL_EXPORTER_JAEGER_ENDPOINT,
OTEL_EXPORTER_ZIPKIN_URL,
OTEL_EXPORTER_COLLECTOR_URL,
JAMBONES_LOGLEVEL,
JAMBONES_CLUSTER_ID,
PORT,
HTTP_PORT_MAX,
K8S,
K8S_SBC_SIP_SERVICE_NAME,
JAMBONES_SUBNET,
NODE_ENV,
JAMBONZ_CLEANUP_INTERVAL_MINS,
getCleanupIntervalMins,
checkEnvs,
AWS_REGION,
AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY,
AWS_SNS_PORT,
AWS_SNS_TOPIC_ARM,
AWS_SNS_PORT_MAX,
ANCHOR_MEDIA_ALWAYS,
VMD_HINTS_FILE,
JAMBONES_FREESWITCH_MAX_CALL_DURATION_MINS,
LEGACY_CRYPTO,
JWT_SECRET,
ENCRYPTION_SECRET,
HTTP_POOL,
HTTP_POOLSIZE,
HTTP_PIPELINING,
HTTP_TIMEOUT,
OPTIONS_PING_INTERVAL,
RESPONSE_TIMEOUT_MS,
JAMBONES_WS_HANDSHAKE_TIMEOUT_MS,
JAMBONES_WS_MAX_PAYLOAD,
JAMBONES_WS_PING_INTERVAL_MS,
MAX_RECONNECTS,
GCP_JSON_KEY,
MICROSOFT_REGION,
MICROSOFT_API_KEY,
SONIOX_API_KEY,
DEEPGRAM_API_KEY,
JAMBONZ_RECORD_WS_BASE_URL,
JAMBONZ_RECORD_WS_USERNAME,
JAMBONZ_RECORD_WS_PASSWORD,
JAMBONZ_DISABLE_DIAL_PAI_HEADER
};

View File

@@ -10,6 +10,7 @@ const HttpRequestor = require('../../utils/http-requestor');
const WsRequestor = require('../../utils/ws-requestor'); const WsRequestor = require('../../utils/ws-requestor');
const RootSpan = require('../../utils/call-tracer'); const RootSpan = require('../../utils/call-tracer');
const dbUtils = require('../../utils/db-utils'); const dbUtils = require('../../utils/db-utils');
const { mergeSdpMedia, extractSdpMedia } = require('../../utils/sdp-utils');
router.post('/', async(req, res) => { router.post('/', async(req, res) => {
const {logger} = req.app.locals; const {logger} = req.app.locals;
@@ -19,14 +20,23 @@ router.post('/', async(req, res) => {
logger.debug({body: req.body}, 'got createCall request'); logger.debug({body: req.body}, 'got createCall request');
try { try {
let uri, cs, to; let uri, cs, to;
// app_json is creaeted by only api-server.
// if it available, take it and delete before creating task
const app_json = req.body.app_json;
delete req.body.app_json;
const restDial = makeTask(logger, {'rest:dial': req.body}); const restDial = makeTask(logger, {'rest:dial': req.body});
const {lookupAccountDetails} = dbUtils(logger, srf); restDial.appJson = app_json;
const {lookupAccountDetails, lookupCarrierByPhoneNumber, lookupCarrier} = dbUtils(logger, srf);
const {
lookupAppBySid
} = srf.locals.dbHelpers;
const {getSBC, getFreeswitch} = srf.locals; const {getSBC, getFreeswitch} = srf.locals;
const sbcAddress = getSBC(); const sbcAddress = getSBC();
if (!sbcAddress) throw new Error('no available SBCs for outbound call creation'); if (!sbcAddress) throw new Error('no available SBCs for outbound call creation');
const target = restDial.to; const target = restDial.to;
const opts = { const opts = {
callingNumber: restDial.from, callingNumber: restDial.from,
...(restDial.callerName && {callingName: restDial.callerName}),
headers: req.body.headers || {} headers: req.body.headers || {}
}; };
@@ -35,6 +45,14 @@ router.post('/', async(req, res) => {
const account = await lookupAccountBySid(req.body.account_sid); const account = await lookupAccountBySid(req.body.account_sid);
const accountInfo = await lookupAccountDetails(req.body.account_sid); const accountInfo = await lookupAccountDetails(req.body.account_sid);
const callSid = uuidv4(); const callSid = uuidv4();
const application = req.body.application_sid ? await lookupAppBySid(req.body.application_sid) : null;
const record_all_calls = account.record_all_calls || (application && application.record_all_calls);
const recordOutputFormat = account.record_format || 'mp3';
const rootSpan = new RootSpan('rest-call', {
callSid,
accountSid,
...(req.body?.application_sid && {'X-Application-Sid': req.body.application_sid})
});
opts.headers = { opts.headers = {
...opts.headers, ...opts.headers,
@@ -42,7 +60,10 @@ router.post('/', async(req, res) => {
'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}) 'X-Trace-ID': rootSpan.traceId,
...(req.body?.application_sid && {'X-Application-Sid': req.body.application_sid}),
...(restDial.fromHost && {'X-Preferred-From-Host': restDial.fromHost}),
...(record_all_calls && {'X-Record-All-Calls': recordOutputFormat})
}; };
switch (target.type) { switch (target.type) {
@@ -76,7 +97,6 @@ router.post('/', async(req, res) => {
} }
if (target.type === 'phone' && target.trunk) { if (target.type === 'phone' && target.trunk) {
const {lookupCarrier} = dbUtils(this.logger, srf);
const voip_carrier_sid = await lookupCarrier(req.body.account_sid, target.trunk); const voip_carrier_sid = await lookupCarrier(req.body.account_sid, target.trunk);
logger.info( logger.info(
`createCall: selected ${voip_carrier_sid} for requested carrier: ${target.trunk || 'unspecified'})`); `createCall: selected ${voip_carrier_sid} for requested carrier: ${target.trunk || 'unspecified'})`);
@@ -85,6 +105,21 @@ router.post('/', async(req, res) => {
} }
} }
/**
* trunk isn't specified,
* check if from-number matches any existing numbers on Jambonz
* */
if (target.type === 'phone' && !target.trunk) {
const str = restDial.from || '';
const callingNumber = str.startsWith('+') ? str.substring(1) : str;
const voip_carrier_sid = await lookupCarrierByPhoneNumber(req.body.account_sid, callingNumber);
logger.info(
`createCall: selected ${voip_carrier_sid} for requested phone number: ${callingNumber || 'unspecified'})`);
if (voip_carrier_sid) {
opts.headers['X-Requested-Carrier-Sid'] = voip_carrier_sid;
}
}
/* create endpoint for outdial */ /* 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');
@@ -93,18 +128,35 @@ router.post('/', async(req, res) => {
/* launch outdial */ /* launch outdial */
let sdp, sipLogger; let sdp, sipLogger;
let dualEp;
let localSdp = ep.local.sdp;
if (req.body.dual_streams) {
dualEp = await ms.createEndpoint();
localSdp = mergeSdpMedia(localSdp, dualEp.local.sdp);
}
const connectStream = async(remoteSdp) => { const connectStream = async(remoteSdp) => {
if (remoteSdp !== sdp) { if (remoteSdp !== sdp) {
ep.modify(sdp = remoteSdp); sdp = remoteSdp;
if (req.body.dual_streams) {
const [sdpLegA, sdpLebB] = extractSdpMedia(remoteSdp);
await ep.modify(sdpLegA);
await dualEp.modify(sdpLebB);
await ep.bridge(dualEp);
} else {
ep.modify(sdp);
}
return true; return true;
} }
return false; return false;
}; };
Object.assign(opts, { Object.assign(opts, {
proxy: `sip:${sbcAddress}`, proxy: `sip:${sbcAddress}`,
localSdp: ep.local.sdp localSdp
}); });
if (target.auth) opts.auth = this.target.auth; if (target.auth) opts.auth = target.auth;
/** /**
@@ -166,7 +218,6 @@ router.post('/', async(req, res) => {
/* ok our outbound INVITE is in flight */ /* ok our outbound INVITE is in flight */
const tasks = [restDial]; const tasks = [restDial];
const rootSpan = new RootSpan('rest-call', inviteReq);
sipLogger = logger.child({ sipLogger = logger.child({
callSid, callSid,
callId: inviteReq.get('Call-ID'), callId: inviteReq.get('Call-ID'),
@@ -190,6 +241,7 @@ router.post('/', async(req, res) => {
srf, srf,
req: inviteReq, req: inviteReq,
ep, ep,
ep2: dualEp,
tasks, tasks,
callInfo, callInfo,
accountInfo, accountInfo,
@@ -230,6 +282,7 @@ router.post('/', async(req, res) => {
sipStatus: err.status, sipStatus: err.status,
sipReason: err.reason sipReason: err.reason
}); });
cs.callGone = true;
} }
else { else {
if (cs) cs.emit('callStatusChange', { if (cs) cs.emit('callStatusChange', {
@@ -241,7 +294,10 @@ router.post('/', async(req, res) => {
else console.error(err); else console.error(err);
} }
ep.destroy(); ep.destroy();
setTimeout(restDial.kill.bind(restDial), 5000); if (dualEp) {
dualEp.destroy();
}
setTimeout(restDial.kill.bind(restDial, cs), 5000);
} }
} catch (err) { } catch (err) {
sysError(logger, res, err); sysError(logger, res, err);

View File

@@ -9,25 +9,29 @@ const {CallStatus, CallDirection} = require('../../utils/constants');
*/ */
function retrieveCallSession(callSid, opts) { function retrieveCallSession(callSid, opts) {
if (opts.call_status_hook && !opts.call_hook) { if (opts.call_status_hook && !opts.call_hook) {
throw new DbErrorBadRequest('call_status_hook can be updated only when call_hook is also being updated'); throw new DbErrorBadRequest(
`call_status_hook can be updated only when call_hook is also being updated for call_sid ${callSid}`);
} }
const cs = sessionTracker.get(callSid); const cs = sessionTracker.get(callSid);
if (!cs) { if (!cs) {
throw new DbErrorUnprocessableRequest('call session is gone'); throw new DbErrorUnprocessableRequest(`call session is gone for call_sid ${callSid}`);
} }
if (opts.call_status === CallStatus.Completed && !cs.hasStableDialog) { if (opts.call_status === CallStatus.Completed && !cs.hasStableDialog) {
throw new DbErrorUnprocessableRequest('current call state is incompatible with requested action'); throw new DbErrorUnprocessableRequest(
`current call state is incompatible with requested action for call_sid ${callSid}`);
} }
else if (opts.call_status === CallStatus.NoAnswer) { else if (opts.call_status === CallStatus.NoAnswer) {
if (cs.direction === CallDirection.Outbound) { if (cs.direction === CallDirection.Outbound) {
if (!cs.isOutboundCallRinging) { if (!cs.isOutboundCallRinging) {
throw new DbErrorUnprocessableRequest('current call state is incompatible with requested action'); throw new DbErrorUnprocessableRequest(
`current call state is incompatible with requested action for call_sid ${callSid}`);
} }
} }
else { else {
if (cs.isInboundCallAnswered) { if (cs.isInboundCallAnswered) {
throw new DbErrorUnprocessableRequest('current call state is incompatible with requested action'); throw new DbErrorUnprocessableRequest(
`current call state is incompatible with requested action for call_sid ${callSid}`);
} }
} }
} }

View File

@@ -10,6 +10,9 @@ 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');
const {
JAMBONES_MYSQL_REFRESH_TTL
} = require('./config');
module.exports = function(srf, logger) { module.exports = function(srf, logger) {
const { const {
@@ -27,7 +30,11 @@ module.exports = function(srf, logger) {
function initLocals(req, res, next) { function initLocals(req, res, next) {
const callId = req.get('Call-ID'); const callId = req.get('Call-ID');
logger.info({callId}, 'new incoming call'); 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);
@@ -42,7 +49,16 @@ module.exports = function(srf, logger) {
} }
if (req.has('X-Authenticated-User')) req.locals.originatingUser = req.get('X-Authenticated-User'); if (req.has('X-Authenticated-User')) req.locals.originatingUser = req.get('X-Authenticated-User');
if (req.has('X-MS-Teams-Tenant-FQDN')) req.locals.msTeamsTenant = req.get('X-MS-Teams-Tenant-FQDN'); if (req.has('X-MS-Teams-Tenant-FQDN')) req.locals.msTeamsTenant = req.get('X-MS-Teams-Tenant-FQDN');
if (req.has('X-Cisco-Recording-Participant')) {
const ciscoParticipants = req.get('X-Cisco-Recording-Participant');
const regex = /sip:[\d]+@[\d]+\.[\d]+\.[\d]+\.[\d]+/g;
const sipURIs = ciscoParticipants.match(regex);
logger.info(`X-Cisco-Recording-Participant : ${sipURIs} `);
if (sipURIs && sipURIs.length > 0) {
req.locals.calledNumber = sipURIs[0];
req.locals.callingNumber = sipURIs[1];
}
}
next(); next();
} }
@@ -90,8 +106,10 @@ module.exports = function(srf, logger) {
.find((p) => p.type === 'application/sdp') .find((p) => p.type === 'application/sdp')
.content; .content;
const {sdp1, sdp2, ...metadata} = await parseSiprecPayload(req, logger); const {sdp1, sdp2, ...metadata} = await parseSiprecPayload(req, logger);
req.locals.calledNumber = metadata.caller.number; if (!req.locals.calledNumber && !req.locals.calledNumber) {
req.locals.callingNumber = metadata.callee.number; req.locals.calledNumber = metadata.caller.number;
req.locals.callingNumber = metadata.callee.number;
}
req.locals = { req.locals = {
...req.locals, ...req.locals,
siprec: { siprec: {
@@ -227,11 +245,12 @@ module.exports = function(srf, logger) {
*/ */
/* allow for caching data - when caching treat retrieved data as immutable */ /* 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; const app2 = 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://')) {
app2.requestor = new WsRequestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret) ; const requestor = new WsRequestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret) ;
app2.notifier = app.requestor; app2.requestor = requestor;
app2.notifier = requestor;
app2.call_hook.method = 'WS'; app2.call_hook.method = 'WS';
} }
else { else {
@@ -270,7 +289,7 @@ module.exports = function(srf, logger) {
const {rootSpan, siprec, application:app} = req.locals; const {rootSpan, siprec, application:app} = req.locals;
let span; let span;
try { try {
if (app.tasks && !process.env.JAMBONES_MYSQL_REFRESH_TTL) { if (app.tasks && !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();
@@ -280,22 +299,30 @@ module.exports = function(srf, logger) {
if (app.app_json) { if (app.app_json) {
json = JSON.parse(app.app_json); json = JSON.parse(app.app_json);
} else { } else {
const defaults = {
synthesizer: {
vendor: app.speech_synthesis_vendor,
...(app.speech_synthesis_label && {label: app.speech_synthesis_label}),
language: app.speech_synthesis_language,
voice: app.speech_synthesis_voice,
...(app.fallback_speech_synthesis_vendor && {fallback_vendor: app.fallback_speech_synthesis_vendor}),
...(app.fallback_speech_synthesis_label && {fallback_label: app.fallback_speech_synthesis_label}),
...(app.fallback_speech_synthesis_language && {fallback_language: app.fallback_speech_synthesis_language}),
...(app.fallback_speech_synthesis_voice && {fallback_voice: app.fallback_speech_synthesis_voice})
},
recognizer: {
vendor: app.speech_recognizer_vendor,
...(app.speech_synthesis_label && {label: app.speech_synthesis_label}),
language: app.speech_recognizer_language,
...(app.fallback_speech_recognizer_vendor && {fallback_vendor: app.fallback_speech_recognizer_vendor}),
...(app.fallback_speech_recognizer_label && {fallback_label: app.fallback_speech_recognizer_label}),
...(app.fallback_speech_recognizer_language && {fallback_language: app.fallback_speech_recognizer_language})
}
};
const params = Object.assign(['POST', 'WS'].includes(app.call_hook.method) ? { sip: req.msg } : {}, const params = Object.assign(['POST', 'WS'].includes(app.call_hook.method) ? { sip: req.msg } : {},
req.locals.callInfo, req.locals.callInfo,
{ service_provider_sid: req.locals.service_provider_sid }, { service_provider_sid: req.locals.service_provider_sid },
{ { defaults });
defaults: {
synthesizer: {
vendor: app.speech_synthesis_vendor,
language: app.speech_synthesis_language,
voice: app.speech_synthesis_voice
},
recognizer: {
vendor: app.speech_recognizer_vendor,
language: app.speech_recognizer_language
}
}
});
logger.debug({ params }, 'sending initial webhook'); logger.debug({ params }, 'sending initial webhook');
const obj = rootSpan.startChildSpan('performAppWebhook'); const obj = rootSpan.startChildSpan('performAppWebhook');
span = obj.span; span = obj.span;
@@ -303,6 +330,7 @@ module.exports = function(srf, logger) {
const httpHeaders = b3 && { b3 }; const httpHeaders = b3 && { b3 };
json = await app.requestor.request('session:new', app.call_hook, params, httpHeaders); 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,

View File

@@ -1,6 +1,7 @@
const {CallDirection, CallStatus} = require('../utils/constants'); const {CallDirection, CallStatus} = require('../utils/constants');
const parseUri = require('drachtio-srf').parseUri; const parseUri = require('drachtio-srf').parseUri;
const uuidv4 = require('uuid-random'); const uuidv4 = require('uuid-random');
const {JAMBONES_API_BASE_URL} = require('../config');
/** /**
* @classdesc Represents the common information for all calls * @classdesc Represents the common information for all calls
* that is provided in call status webhooks * that is provided in call status webhooks
@@ -33,6 +34,23 @@ class CallInfo {
this.callStatus = CallStatus.Trying; this.callStatus = CallStatus.Trying;
this.originatingSipIp = req.get('X-Forwarded-For'); this.originatingSipIp = req.get('X-Forwarded-For');
this.originatingSipTrunkName = req.get('X-Originating-Carrier'); this.originatingSipTrunkName = req.get('X-Originating-Carrier');
const {siprec} = req.locals;
if (siprec) {
const caller = parseUri(req.locals.callingNumber);
const callee = parseUri(req.locals.calledNumber);
this.participants = [
{
participant: 'caller',
uriUser: caller?.user,
uriHost: caller?.host
},
{
participant: 'callee',
uriUser: callee?.user,
uriHost: callee?.host
}
];
}
} }
else if (opts.parentCallInfo) { else if (opts.parentCallInfo) {
// outbound call that is a child of an existing call // outbound call that is a child of an existing call
@@ -129,8 +147,8 @@ class CallInfo {
Object.assign(obj, {customerData: this._customerData}); Object.assign(obj, {customerData: this._customerData});
} }
if (process.env.JAMBONES_API_BASE_URL) { if (JAMBONES_API_BASE_URL) {
Object.assign(obj, {apiBaseUrl: process.env.JAMBONES_API_BASE_URL}); Object.assign(obj, {apiBaseUrl: JAMBONES_API_BASE_URL});
} }
if (this.publicIp) { if (this.publicIp) {
Object.assign(obj, {fsPublicIp: this.publicIp}); Object.assign(obj, {fsPublicIp: this.publicIp});

View File

@@ -17,6 +17,13 @@ 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');
const {
JAMBONES_INJECT_CONTENT,
AWS_REGION,
JAMBONZ_RECORD_WS_BASE_URL,
JAMBONZ_RECORD_WS_USERNAME,
JAMBONZ_RECORD_WS_PASSWORD,
} = require('../config');
const BADPRECONDITIONS = 'preconditions not met'; const BADPRECONDITIONS = 'preconditions not met';
const CALLER_CANCELLED_ERR_MSG = 'Response not sent due to unknown transaction'; const CALLER_CANCELLED_ERR_MSG = 'Response not sent due to unknown transaction';
@@ -60,6 +67,16 @@ class CallSession extends Emitter {
this.notifiedComplete = false; this.notifiedComplete = false;
this.rootSpan = rootSpan; this.rootSpan = rootSpan;
this._origRecognizerSettings = {
vendor: this.application?.speech_recognizer_vendor,
language: this.application?.speech_recognizer_language,
};
this._origSynthesizerSettings = {
vendor: this.application?.speech_synthesis_vendor,
language: this.application?.speech_synthesis_language,
voice: this.application?.speech_synthesis_voice,
};
assert(rootSpan); assert(rootSpan);
this._recordState = RecordState.RecordingOff; this._recordState = RecordState.RecordingOff;
@@ -118,6 +135,11 @@ class CallSession extends Emitter {
return this.callInfo.callStatus; return this.callInfo.callStatus;
} }
get isBackGroundListen() {
return !(this.backgroundListenTask === null ||
this.backgroundListenTask === undefined);
}
/** /**
* SIP call-id for the call * SIP call-id for the call
*/ */
@@ -157,6 +179,30 @@ class CallSession extends Emitter {
set speechSynthesisVendor(vendor) { set speechSynthesisVendor(vendor) {
this.application.speech_synthesis_vendor = vendor; this.application.speech_synthesis_vendor = vendor;
} }
get fallbackSpeechSynthesisVendor() {
return this.application.fallback_speech_synthesis_vendor;
}
set fallbackSpeechSynthesisVendor(vendor) {
this.application.fallback_speech_synthesis_vendor = vendor;
}
/**
* default label to use for speech synthesis if not provided in the app
*/
get speechSynthesisLabel() {
return this.application.speech_synthesis_label;
}
set speechSynthesisLabel(label) {
this.application.speech_synthesis_label = label;
}
get fallbackSpeechSynthesisLabel() {
return this.application.fallback_speech_synthesis_label;
}
set fallbackSpeechSynthesisLabel(label) {
this.application.fallback_speech_synthesis_label = label;
}
/** /**
* default voice to use for speech synthesis if not provided in the app * default voice to use for speech synthesis if not provided in the app
*/ */
@@ -166,6 +212,13 @@ class CallSession extends Emitter {
set speechSynthesisVoice(voice) { set speechSynthesisVoice(voice) {
this.application.speech_synthesis_voice = voice; this.application.speech_synthesis_voice = voice;
} }
get fallbackSpeechSynthesisVoice() {
return this.application.fallback_speech_synthesis_voice;
}
set fallbackSpeechSynthesisVoice(voice) {
this.application.fallback_speech_synthesis_voice = voice;
}
/** /**
* default language to use for speech synthesis if not provided in the app * default language to use for speech synthesis if not provided in the app
*/ */
@@ -176,6 +229,13 @@ class CallSession extends Emitter {
this.application.speech_synthesis_language = language; this.application.speech_synthesis_language = language;
} }
get fallbackSpeechSynthesisLanguage() {
return this.application.fallback_speech_synthesis_language;
}
set fallbackSpeechSynthesisLanguage(language) {
this.application.fallback_speech_synthesis_language = language;
}
/** /**
* default vendor to use for speech recognition if not provided in the app * default vendor to use for speech recognition if not provided in the app
*/ */
@@ -185,6 +245,29 @@ class CallSession extends Emitter {
set speechRecognizerVendor(vendor) { set speechRecognizerVendor(vendor) {
this.application.speech_recognizer_vendor = vendor; this.application.speech_recognizer_vendor = vendor;
} }
get fallbackSpeechRecognizerVendor() {
return this.application.fallback_speech_recognizer_vendor;
}
set fallbackSpeechRecognizerVendor(vendor) {
this.application.fallback_speech_recognizer_vendor = vendor;
}
/**
* default vendor to use for speech recognition if not provided in the app
*/
get speechRecognizerLabel() {
return this.application.speech_recognizer_label;
}
set speechRecognizerLabel(label) {
this.application.speech_recognizer_label = label;
}
get fallbackSpeechRecognizerLabel() {
return this.application.fallback_speech_recognizer_label;
}
set fallbackSpeechRecognizerLabel(label) {
this.application.fallback_speech_recognizer_label = label;
}
/** /**
* default language to use for speech recognition if not provided in the app * default language to use for speech recognition if not provided in the app
*/ */
@@ -195,6 +278,13 @@ class CallSession extends Emitter {
this.application.speech_recognizer_language = language; this.application.speech_recognizer_language = language;
} }
get fallbackSpeechRecognizerLanguage() {
return this.application.fallback_speech_recognizer_language;
}
set fallbackSpeechRecognizerLanguage(language) {
this.application.fallback_speech_recognizer_language = language;
}
/** /**
* indicates whether the call currently in progress * indicates whether the call currently in progress
*/ */
@@ -316,10 +406,42 @@ class CallSession extends Emitter {
return this._globalSttPunctuation; return this._globalSttPunctuation;
} }
get onHoldMusic() {
return this._onHoldMusic;
}
set onHoldMusic(url) {
this._onHoldMusic = url;
}
get sipRequestWithinDialogHook() {
return this._sipRequestWithinDialogHook;
}
set sipRequestWithinDialogHook(url) {
this._sipRequestWithinDialogHook = url;
}
hasGlobalSttPunctuation() { hasGlobalSttPunctuation() {
return this._globalSttPunctuation !== undefined; return this._globalSttPunctuation !== undefined;
} }
resetRecognizer() {
this._globalSttHints = undefined;
this._globalSttPunctuation = undefined;
this._globalAltLanguages = undefined;
this.isContinuousAsr = false;
this.asrDtmfTerminationDigits = undefined;
this.speechRecognizerLanguage = this._origRecognizerSettings.language;
this.speechRecognizerVendor = this._origRecognizerSettings.vendor;
}
resetSynthesizer() {
this.speechSynthesisLanguage = this._origSynthesizerSettings.language;
this.speechSynthesisVendor = this._origSynthesizerSettings.vendor;
this.speechSynthesisVoice = this._origSynthesizerSettings.voice;
}
async notifyRecordOptions(opts) { async notifyRecordOptions(opts) {
const {action} = opts; const {action} = opts;
this.logger.debug({opts}, 'CallSession:notifyRecordOptions'); this.logger.debug({opts}, 'CallSession:notifyRecordOptions');
@@ -385,7 +507,10 @@ class CallSession extends Emitter {
'X-Call-Sid': this.callSid, 'X-Call-Sid': this.callSid,
'X-Account-Sid': this.accountSid, 'X-Account-Sid': this.accountSid,
'X-Application-Sid': this.applicationSid, 'X-Application-Sid': this.applicationSid,
} ...(this.recordOptions.headers && {'Content-Type': 'application/json'})
},
// Siprect Client is initiated from startCallRecording, so just need to pass custom headers in startRecording
...(this.recordOptions.headers && {body: JSON.stringify(this.recordOptions.headers) + '\n'})
}); });
if (res.status === 200) { if (res.status === 200) {
this._recordState = RecordState.RecordingOn; this._recordState = RecordState.RecordingOn;
@@ -406,7 +531,7 @@ class CallSession extends Emitter {
const res = await this.dlg.request({ const res = await this.dlg.request({
method: 'INFO', method: 'INFO',
headers: { headers: {
'X-Reason': 'stopCallRecording', 'X-Reason': 'stopCallRecording'
} }
}); });
if (res.status === 200) { if (res.status === 200) {
@@ -428,7 +553,7 @@ class CallSession extends Emitter {
const res = await this.dlg.request({ const res = await this.dlg.request({
method: 'INFO', method: 'INFO',
headers: { headers: {
'X-Reason': 'pauseCallRecording', 'X-Reason': 'pauseCallRecording'
} }
}); });
if (res.status === 200) { if (res.status === 200) {
@@ -450,7 +575,7 @@ class CallSession extends Emitter {
const res = await this.dlg.request({ const res = await this.dlg.request({
method: 'INFO', method: 'INFO',
headers: { headers: {
'X-Reason': 'resumeCallRecording', 'X-Reason': 'resumeCallRecording'
} }
}); });
if (res.status === 200) { if (res.status === 200) {
@@ -465,7 +590,7 @@ class CallSession extends Emitter {
} }
} }
async startBackgroundListen(opts) { async startBackgroundListen(opts, bugname) {
if (this.isListenEnabled) { if (this.isListenEnabled) {
this.logger.info('CallSession:startBackgroundListen - listen is already enabled, ignoring request'); this.logger.info('CallSession:startBackgroundListen - listen is already enabled, ignoring request');
return; return;
@@ -474,8 +599,11 @@ class CallSession extends Emitter {
this.logger.debug({opts}, 'CallSession:startBackgroundListen'); this.logger.debug({opts}, 'CallSession:startBackgroundListen');
const t = normalizeJambones(this.logger, [opts]); const t = normalizeJambones(this.logger, [opts]);
this.backgroundListenTask = makeTask(this.logger, t[0]); this.backgroundListenTask = makeTask(this.logger, t[0]);
this.backgroundListenTask.bugname = bugname;
// Remove unneeded customer data to be sent to api server.
this.backgroundListenTask.ignoreCustomerData = true;
const resources = await this._evaluatePreconditions(this.backgroundListenTask); const resources = await this._evaluatePreconditions(this.backgroundListenTask);
const {span, ctx} = this.rootSpan.startChildSpan(`background-gather:${this.backgroundListenTask.summary}`); const {span, ctx} = this.rootSpan.startChildSpan(`background-listen:${this.backgroundListenTask.summary}`);
this.backgroundListenTask.span = span; this.backgroundListenTask.span = span;
this.backgroundListenTask.ctx = ctx; this.backgroundListenTask.ctx = ctx;
this.backgroundListenTask.exec(this, resources) this.backgroundListenTask.exec(this, resources)
@@ -498,6 +626,7 @@ class CallSession extends Emitter {
} }
async stopBackgroundListen() { async stopBackgroundListen() {
this.logger.debug('CallSession:stopBackgroundListen');
try { try {
if (this.backgroundListenTask) { if (this.backgroundListenTask) {
this.backgroundListenTask.removeAllListeners(); this.backgroundListenTask.removeAllListeners();
@@ -506,17 +635,28 @@ class CallSession extends Emitter {
} catch (err) { } catch (err) {
this.logger.info({err}, 'CallSession:stopBackgroundListen - Error stopping listen task'); 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]); const task = makeTask(this.logger, t[0]);
if (this.isBotModeEnabled) {
const currInput = this.backgroundGatherTask.input;
const newInput = task.input;
if (JSON.stringify(currInput) === JSON.stringify(newInput)) {
this.logger.info('CallSession:enableBotMode - bot mode currently enabled, ignoring request to start again');
return;
}
else {
this.logger.info({currInput, newInput},
'CallSession:enableBotMode - restarting background gather to apply new input type');
this.backgroundGatherTask.sticky = false;
await this.disableBotMode();
}
}
this.backgroundGatherTask = task;
this._bargeInEnabled = true; this._bargeInEnabled = true;
this.backgroundGatherTask this.backgroundGatherTask
.once('dtmf', this._clearTasks.bind(this, this.backgroundGatherTask)) .once('dtmf', this._clearTasks.bind(this, this.backgroundGatherTask))
@@ -528,13 +668,15 @@ class CallSession extends Emitter {
const {span, ctx} = this.rootSpan.startChildSpan(`background-gather:${this.backgroundGatherTask.summary}`); const {span, ctx} = this.rootSpan.startChildSpan(`background-gather:${this.backgroundGatherTask.summary}`);
this.backgroundGatherTask.span = span; this.backgroundGatherTask.span = span;
this.backgroundGatherTask.ctx = ctx; this.backgroundGatherTask.ctx = ctx;
this.backgroundGatherTask.sticky = autoEnable;
this.backgroundGatherTask.exec(this, resources) this.backgroundGatherTask.exec(this, resources)
.then(() => { .then(() => {
this.logger.info('CallSession:enableBotMode: gather completed'); this.logger.info('CallSession:enableBotMode: gather completed');
this.backgroundGatherTask && this.backgroundGatherTask.removeAllListeners(); this.backgroundGatherTask && this.backgroundGatherTask.removeAllListeners();
this.backgroundGatherTask && this.backgroundGatherTask.span.end(); this.backgroundGatherTask && this.backgroundGatherTask.span.end();
const sticky = this.backgroundGatherTask?.sticky;
this.backgroundGatherTask = null; this.backgroundGatherTask = null;
if (autoEnable && !this.callGone && !this._stopping && this._bargeInEnabled) { if (sticky && !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));
} }
@@ -550,12 +692,12 @@ class CallSession extends Emitter {
this.logger.info({err, gather}, 'CallSession:enableBotMode - Error creating gather task'); this.logger.info({err, gather}, 'CallSession:enableBotMode - Error creating gather task');
} }
} }
disableBotMode() { async disableBotMode() {
this._bargeInEnabled = false; this._bargeInEnabled = false;
if (this.backgroundGatherTask) { if (this.backgroundGatherTask) {
try { try {
this.backgroundGatherTask.removeAllListeners(); this.backgroundGatherTask.removeAllListeners();
this.backgroundGatherTask.kill().catch((err) => {}); await this.backgroundGatherTask.kill();
} catch (err) {} } catch (err) {}
this.backgroundGatherTask = null; this.backgroundGatherTask = null;
} }
@@ -582,15 +724,28 @@ class CallSession extends Emitter {
* Check for speech credentials for the specified vendor * Check for speech credentials for the specified vendor
* @param {*} vendor - google or aws * @param {*} vendor - google or aws
*/ */
getSpeechCredentials(vendor, type) { getSpeechCredentials(vendor, type, label = null) {
const {writeAlerts, AlertType} = this.srf.locals; const {writeAlerts, AlertType} = this.srf.locals;
if (this.accountInfo.speech && this.accountInfo.speech.length > 0) { if (this.accountInfo.speech && this.accountInfo.speech.length > 0) {
const credential = this.accountInfo.speech.find((s) => s.vendor === vendor); // firstly check if account level has expected credential
let credential = this.accountInfo.speech.find((s) => s.vendor === vendor &&
s.label === label && s.account_sid);
if (!credential) {
// check if SP level has expected credential
credential = this.accountInfo.speech.find((s) => s.vendor === vendor &&
s.label === label && !s.account_sid);
}
if (credential && ( if (credential && (
(type === 'tts' && credential.use_for_tts) || (type === 'tts' && credential.use_for_tts) ||
(type === 'stt' && credential.use_for_stt) (type === 'stt' && credential.use_for_stt)
)) { )) {
this.logger.info(
`Speech vendor: ${credential.vendor} ${credential.label ? `, label: ${credential.label}` : ''} selected`);
if ('google' === vendor) { if ('google' === vendor) {
if (type === 'tts' && !credential.tts_tested_ok ||
type === 'stt' && !credential.stt_tested_ok) {
return;
}
try { try {
const cred = JSON.parse(credential.service_key.replace(/\n/g, '\\n')); const cred = JSON.parse(credential.service_key.replace(/\n/g, '\\n'));
return { return {
@@ -612,7 +767,7 @@ class CallSession extends Emitter {
speech_credential_sid: credential.speech_credential_sid, speech_credential_sid: credential.speech_credential_sid,
accessKeyId: credential.access_key_id, accessKeyId: credential.access_key_id,
secretAccessKey: credential.secret_access_key, secretAccessKey: credential.secret_access_key,
region: credential.aws_region || process.env.AWS_REGION region: credential.aws_region || AWS_REGION
}; };
} }
else if ('microsoft' === vendor) { else if ('microsoft' === vendor) {
@@ -622,8 +777,10 @@ class CallSession extends Emitter {
region: credential.region, region: credential.region,
use_custom_stt: credential.use_custom_stt, use_custom_stt: credential.use_custom_stt,
custom_stt_endpoint: credential.custom_stt_endpoint, custom_stt_endpoint: credential.custom_stt_endpoint,
custom_stt_endpoint_url: credential.custom_stt_endpoint_url,
use_custom_tts: credential.use_custom_tts, use_custom_tts: credential.use_custom_tts,
custom_tts_endpoint: credential.custom_tts_endpoint custom_tts_endpoint: credential.custom_tts_endpoint,
custom_tts_endpoint_url: credential.custom_tts_endpoint_url
}; };
} }
else if ('wellsaid' === vendor) { else if ('wellsaid' === vendor) {
@@ -636,7 +793,9 @@ class CallSession extends Emitter {
return { return {
speech_credential_sid: credential.speech_credential_sid, speech_credential_sid: credential.speech_credential_sid,
client_id: credential.client_id, client_id: credential.client_id,
secret: credential.secret secret: credential.secret,
nuance_tts_uri: credential.nuance_tts_uri,
nuance_stt_uri: credential.nuance_stt_uri
}; };
} }
else if ('deepgram' === vendor) { else if ('deepgram' === vendor) {
@@ -645,6 +804,12 @@ class CallSession extends Emitter {
api_key: credential.api_key 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) { else if ('ibm' === vendor) {
return { return {
speech_credential_sid: credential.speech_credential_sid, speech_credential_sid: credential.speech_credential_sid,
@@ -654,6 +819,26 @@ class CallSession extends Emitter {
stt_region: credential.stt_region stt_region: credential.stt_region
}; };
} }
else if ('nvidia' === vendor) {
return {
speech_credential_sid: credential.speech_credential_sid,
riva_server_uri: credential.riva_server_uri
};
}
else if ('cobalt' === vendor) {
return {
speech_credential_sid: credential.speech_credential_sid,
cobalt_server_uri: credential.cobalt_server_uri
};
}
else if (vendor.startsWith('custom:')) {
return {
speech_credential_sid: credential.speech_credential_sid,
auth_token: credential.auth_token,
custom_stt_url: credential.custom_stt_url,
custom_tts_url: credential.custom_tts_url
};
}
} }
else { else {
writeAlerts({ writeAlerts({
@@ -684,17 +869,18 @@ class CallSession extends Emitter {
let skip = false; let skip = false;
this.currentTask = task; this.currentTask = task;
if (TaskName.Gather === task.name && this.isBotModeEnabled) { if (TaskName.Gather === task.name && this.isBotModeEnabled) {
if (this.backgroundGatherTask.updateTaskInProgress(task)) { 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}`);
skip = true; skip = true;
} }
else { else {
this.logger.info('CallSession:exec disabling bot mode to start gather with new options'); this.logger.info('CallSession:exec disabling bot mode to start gather with new options');
this.disableBotMode(); await this.disableBotMode();
} }
} }
if (!skip) { if (!skip) {
const {span, ctx} = this.rootSpan.startChildSpan(`verb:${task.summary}`); const {span, ctx} = this.rootSpan.startChildSpan(`verb:${task.summary}`);
span.setAttributes({'verb.summary': task.summary});
task.span = span; task.span = span;
task.ctx = ctx; task.ctx = ctx;
await task.exec(this, resources); await task.exec(this, resources);
@@ -715,20 +901,15 @@ class CallSession extends Emitter {
} }
} }
if (0 === this.tasks.length && this.requestor instanceof WsRequestor && !this.callGone) { if (0 === this.tasks.length &&
let span; this.requestor instanceof WsRequestor &&
!this.requestor.closedGracefully &&
!this.callGone
) {
try { try {
const {span} = this.rootSpan.startChildSpan('waiting for commands'); await this._awaitCommandsOrHangup();
const {reason, queue, command} = await this._awaitCommandsOrHangup();
span.setAttributes({
'completion.reason': reason,
'async.request.queue': queue,
'async.request.command': command
});
span.end();
if (this.callGone) break; if (this.callGone) break;
} catch (err) { } catch (err) {
span.end();
this.logger.info(err, 'CallSession:exec - error waiting for new commands'); this.logger.info(err, 'CallSession:exec - error waiting for new commands');
break; break;
} }
@@ -748,7 +929,6 @@ class CallSession extends Emitter {
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);
} }
@@ -909,6 +1089,24 @@ class CallSession extends Emitter {
listenTask.updateListen(opts.listen_status); listenTask.updateListen(opts.listen_status);
} }
/**
* perform live call control -- change Transcribe status
* @param {object} opts
* @param {string} opts.transcribe_status - 'pause' or 'resume'
*/
async _lccTranscribeStatus(opts) {
const task = this.currentTask;
if (!task || ![TaskName.Dial, TaskName.Transcribe].includes(task.name)) {
return this.logger.info(`CallSession:_lccTranscribeStatus - invalid transcribe_status in task ${task.name}`);
}
const transcribeTask = task.name === TaskName.Transcribe ? task : task.transcribeTask;
if (!transcribeTask) {
return this.logger
.info('CallSession:_lccTranscribeStatus - invalid transcribe_status: Dial does not have a Transcribe');
}
transcribeTask.updateTranscribe(opts.transcribe_status);
}
async _lccMuteStatus(callSid, mute) { async _lccMuteStatus(callSid, mute) {
// this whole thing requires us to be in a Dial or Conference verb // this whole thing requires us to be in a Dial or Conference verb
const task = this.currentTask; const task = this.currentTask;
@@ -1027,6 +1225,9 @@ class CallSession extends Emitter {
if (opts.listen_status) { if (opts.listen_status) {
await this._lccListenStatus(opts); await this._lccListenStatus(opts);
} }
if (opts.transcribe_status) {
await this._lccTranscribeStatus(opts);
}
else if (opts.mute_status) { else if (opts.mute_status) {
await this._lccMuteStatus(callSid, opts.mute_status === 'mute'); await this._lccMuteStatus(callSid, opts.mute_status === 'mute');
} }
@@ -1123,14 +1324,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);
@@ -1139,10 +1340,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;
@@ -1158,7 +1360,7 @@ class CallSession extends Emitter {
} }
_onCommand({msgid, command, call_sid, queueCommand, data}) { _onCommand({msgid, command, call_sid, queueCommand, data}) {
this.logger.info({msgid, command, queueCommand}, 'CallSession:_onCommand - received command'); this.logger.info({msgid, command, queueCommand, data}, 'CallSession:_onCommand - received command');
const resolution = {reason: 'received command', queue: queueCommand, command}; const resolution = {reason: 'received command', queue: queueCommand, command};
switch (command) { switch (command) {
case 'redirect': case 'redirect':
@@ -1169,13 +1371,11 @@ class CallSession extends Emitter {
this.logger.info({tasks: listTaskNames(t)}, 'CallSession:_onCommand new task list'); this.logger.info({tasks: listTaskNames(t)}, 'CallSession:_onCommand new task list');
this.replaceApplication(t); this.replaceApplication(t);
} }
else if (process.env.JAMBONES_INJECT_CONTENT) { else if (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');
} }
@@ -1204,6 +1404,10 @@ class CallSession extends Emitter {
this._lccListenStatus(data); this._lccListenStatus(data);
break; break;
case 'transcribe:status':
this._lccTranscribeStatus(data);
break;
case 'whisper': case 'whisper':
this._lccWhisper(data, call_sid); this._lccWhisper(data, call_sid);
break; break;
@@ -1219,19 +1423,21 @@ 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;
} }
/*
else { else {
const {span} = this.rootSpan.startChildSpan('async command');
const {queue, command} = resolution; const {queue, command} = resolution;
const {span} = this.rootSpan.startChildSpan(`recv cmd: ${command}`);
span.setAttributes({ span.setAttributes({
'async.request.queue': queue, 'async.request.queue': queue,
'async.request.command': command 'async.request.command': command
}); });
span.end(); span.end();
} }
*/
} }
_onWsConnectionDropped() { _onWsConnectionDropped() {
@@ -1271,7 +1477,10 @@ class CallSession extends Emitter {
} }
// we are going from an early media connection to answer // we are going from an early media connection to answer
await this.propagateAnswer(); if (this.direction === CallDirection.Inbound) {
// only do this for inbound call.
await this.propagateAnswer();
}
return { return {
...resources, ...resources,
...(this.isSipRecCallSession && {ep2: this.ep2}) ...(this.isSipRecCallSession && {ep2: this.ep2})
@@ -1291,6 +1500,8 @@ class CallSession extends Emitter {
this.ep = ep; this.ep = ep;
this.logger.debug(`allocated endpoint ${ep.uuid}`); this.logger.debug(`allocated endpoint ${ep.uuid}`);
this._configMsEndpoint();
this.ep.on('destroy', () => { this.ep.on('destroy', () => {
this.logger.debug(`endpoint was destroyed!! ${this.ep.uuid}`); this.logger.debug(`endpoint was destroyed!! ${this.ep.uuid}`);
}); });
@@ -1361,6 +1572,7 @@ 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});
this._configMsEndpoint();
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');
@@ -1442,17 +1654,49 @@ class CallSession extends Emitter {
} }
this.dlg.on('modify', this._onReinvite.bind(this)); this.dlg.on('modify', this._onReinvite.bind(this));
this.dlg.on('refer', this._onRefer.bind(this)); this.dlg.on('refer', this._onRefer.bind(this));
if (this.sipRequestWithinDialogHook) {
this.dlg.on('info', this._onRequestWithinDialog.bind(this));
this.dlg.on('message', this._onRequestWithinDialog.bind(this));
}
this.logger.debug(`CallSession:propagateAnswer - answered callSid ${this.callSid}`); this.logger.debug(`CallSession:propagateAnswer - answered callSid ${this.callSid}`);
} }
} }
async _onRequestWithinDialog(req, res) {
if (!this.sipRequestWithinDialogHook) {
return;
}
const sip_method = req.method;
if (sip_method === 'INFO') {
res.send(200);
} else if (sip_method === 'MESSAGE') {
res.send(202);
} else {
this.logger.info(`CallSession:_onRequestWithinDialog unsported method: ${req.method}`);
res.send(501);
return;
}
const params = {sip_method, sip_body: req.body};
this.currentTask.performHook(this, this.sipRequestWithinDialogHook, params);
}
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 {
if (this.currentTask.name === TaskName.Dial && this.currentTask.isOnHold) {
this.logger.info('onholdMusic reINVITE after media has been released');
await this.currentTask.handleReinviteAfterMediaReleased(req, res);
} 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');
@@ -1498,6 +1742,7 @@ 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});
this._configMsEndpoint();
} }
return {ms: this.ms, ep: this.ep}; return {ms: this.ms, ep: this.ep};
} }
@@ -1511,7 +1756,7 @@ class CallSession extends Emitter {
const pp = this._pool.promise(); const pp = this._pool.promise();
try { try {
this.logger.info({accountSid: this.accountSid}, 'performQueueWebhook: looking up account'); this.logger.info({accountSid: this.accountSid}, 'performQueueWebhook: looking up account');
const [r] = await pp.query(sqlRetrieveQueueEventHook, this.accountSid); const [r] = await pp.query(sqlRetrieveQueueEventHook, [this.accountSid]);
if (0 === r.length) { if (0 === r.length) {
this.logger.info({accountSid: this.accountSid}, 'performQueueWebhook: no webhook provisioned'); this.logger.info({accountSid: this.accountSid}, 'performQueueWebhook: no webhook provisioned');
this.queueEventHookRequestor = null; this.queueEventHookRequestor = null;
@@ -1652,6 +1897,7 @@ class CallSession extends Emitter {
'X-Reason': 'anchor-media' 'X-Reason': 'anchor-media'
} }
}); });
this._configMsEndpoint();
} }
async handleReinviteAfterMediaReleased(req, res) { async handleReinviteAfterMediaReleased(req, res) {
@@ -1673,6 +1919,14 @@ class CallSession extends Emitter {
async _notifyCallStatusChange({callStatus, sipStatus, sipReason, duration}) { async _notifyCallStatusChange({callStatus, sipStatus, sipReason, duration}) {
if (this.callMoved) return; if (this.callMoved) return;
if (callStatus === CallStatus.InProgress) {
// nice, call is in progress, good time to enable record
await this.enableRecordAllCall();
} else if (callStatus == CallStatus.Completed && this.isBackGroundListen) {
this.stopBackgroundListen().catch((err) => this.logger.error(
{err}, 'CallSession:_notifyCallStatusChange - error stopping background listen'));
}
/* race condition: we hang up at the same time as the caller */ /* race condition: we hang up at the same time as the caller */
if (callStatus === CallStatus.Completed) { if (callStatus === CallStatus.Completed) {
if (this.notifiedComplete) return; if (this.notifiedComplete) return;
@@ -1685,6 +1939,15 @@ class CallSession 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;
this.executeStatusCallback(callStatus, sipStatus);
// update calls db
//this.logger.debug(`updating redis with ${JSON.stringify(this.callInfo)}`);
this.updateCallStatus(Object.assign({}, this.callInfo.toJSON()), this.serviceUrl)
.catch((err) => this.logger.error(err, 'redis error'));
}
async executeStatusCallback(callStatus, sipStatus) {
const {span} = this.rootSpan.startChildSpan(`call-status:${this.callInfo.callStatus}`); const {span} = this.rootSpan.startChildSpan(`call-status:${this.callInfo.callStatus}`);
span.setAttributes(this.callInfo.toJSON()); span.setAttributes(this.callInfo.toJSON());
try { try {
@@ -1696,11 +1959,35 @@ class CallSession extends Emitter {
span.end(); span.end();
this.logger.info(err, `CallSession:_notifyCallStatusChange error sending ${callStatus} ${sipStatus}`); this.logger.info(err, `CallSession:_notifyCallStatusChange error sending ${callStatus} ${sipStatus}`);
} }
}
// update calls db async enableRecordAllCall() {
//this.logger.debug(`updating redis with ${JSON.stringify(this.callInfo)}`); if (this.accountInfo.account.record_all_calls || this.application.record_all_calls) {
this.updateCallStatus(Object.assign({}, this.callInfo.toJSON()), this.serviceUrl) if (!JAMBONZ_RECORD_WS_BASE_URL || !this.accountInfo.account.bucket_credential) {
.catch((err) => this.logger.error(err, 'redis error')); this.logger.error('Record all calls: invalid configuration');
return;
}
const listenOpts = {
url: `${JAMBONZ_RECORD_WS_BASE_URL}/record/${this.accountInfo.account.bucket_credential.vendor}`,
disableBidirectionalAudio: true,
mixType : 'stereo',
passDtmf: true
};
if (JAMBONZ_RECORD_WS_USERNAME && JAMBONZ_RECORD_WS_PASSWORD) {
listenOpts.wsAuth = {
username: JAMBONZ_RECORD_WS_USERNAME,
password: JAMBONZ_RECORD_WS_PASSWORD
};
}
this.logger.debug({listenOpts}, 'Record all calls: enabling listen');
await this.startBackgroundListen({verb: 'listen', ...listenOpts}, 'jambonz-session-record');
}
}
_configMsEndpoint() {
if (this.onHoldMusic) {
this.ep.set({hold_music: `shout://${this.onHoldMusic.replace(/^https?:\/\//, '')}`});
}
} }
/** /**

View File

@@ -8,7 +8,7 @@ const moment = require('moment');
* @extends CallSession * @extends CallSession
*/ */
class RestCallSession extends CallSession { class RestCallSession extends CallSession {
constructor({logger, application, srf, req, ep, tasks, callInfo, accountInfo, rootSpan}) { constructor({logger, application, srf, req, ep, ep2, tasks, callInfo, accountInfo, rootSpan}) {
super({ super({
logger, logger,
application, application,
@@ -21,6 +21,11 @@ class RestCallSession extends CallSession {
}); });
this.req = req; this.req = req;
this.ep = ep; this.ep = ep;
this.ep2 = ep2;
// keep restDialTask reference for closing AMD
if (tasks.length) {
this.restDialTask = tasks[0];
}
this.on('callStatusChange', this._notifyCallStatusChange.bind(this)); this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
this._notifyCallStatusChange({ this._notifyCallStatusChange({
@@ -44,6 +49,9 @@ class RestCallSession extends CallSession {
* This is invoked when the called party hangs up, in order to calculate the call duration. * This is invoked when the called party hangs up, in order to calculate the call duration.
*/ */
_callerHungup() { _callerHungup() {
if (this.restDialTask) {
this.restDialTask.turnOffAmd();
}
this.callInfo.callTerminationBy = 'caller'; 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});

View File

@@ -1,6 +1,7 @@
const InboundCallSession = require('./inbound-call-session'); const InboundCallSession = require('./inbound-call-session');
const {createSipRecPayload} = require('../utils/siprec-utils'); const {createSipRecPayload} = require('../utils/siprec-utils');
const {CallStatus} = require('../utils/constants'); const {CallStatus} = require('../utils/constants');
const {parseSiprecPayload} = require('../utils/siprec-utils');
/** /**
* @classdesc Subclass of InboundCallSession. This represents a CallSession that is * @classdesc Subclass of InboundCallSession. This represents a CallSession that is
* established for an inbound SIPREC call. * established for an inbound SIPREC call.
@@ -16,6 +17,32 @@ class SipRecCallSession extends InboundCallSession {
this.metadata = metadata; this.metadata = metadata;
} }
async _onReinvite(req, res) {
try {
this.logger.info(req.payload, 'SipRec Re-INVITE payload');
const {sdp1: reSdp1, sdp2: reSdp2, metadata: reMetadata} = await parseSiprecPayload(req, this.logger);
this.sdp1 = reSdp1;
this.sdp2 = reSdp2;
this.metadata = reMetadata;
if (this.ep && this.ep2) {
let remoteSdp = this.sdp1.replace(/sendonly/, 'sendrecv');
const newSdp1 = await this.ep.modify(remoteSdp);
remoteSdp = this.sdp2.replace(/sendonly/, 'sendrecv');
const newSdp2 = await this.ep2.modify(remoteSdp);
const combinedSdp = await createSipRecPayload(newSdp1, newSdp2, this.logger);
res.send(200, {body: combinedSdp});
this.logger.info({offer: req.body, answer: combinedSdp}, 'SipRec handling reINVITE');
}
else {
this.logger.info('got SipRec reINVITE but no endpoint and media has not been released');
res.send(488);
}
} catch (err) {
this.logger.error(err, 'Error handling reinvite');
}
}
async answerSipRecCall() { async answerSipRecCall() {
try { try {
this.ms = this.getMS(); this.ms = this.getMS();

View File

@@ -48,7 +48,7 @@ class Conference extends Task {
this.confName = this.data.name; this.confName = this.data.name;
[ [
'beep', 'startConferenceOnEnter', 'endConferenceOnExit', 'joinMuted', 'beep', 'startConferenceOnEnter', 'endConferenceOnExit', 'joinMuted',
'maxParticipants', 'waitHook', 'statusHook', 'endHook', 'enterHook' 'maxParticipants', 'waitHook', 'statusHook', 'endHook', 'enterHook', 'endConferenceDuration'
].forEach((attr) => this[attr] = this.data[attr]); ].forEach((attr) => this[attr] = this.data[attr]);
this.record = this.data.record || {}; this.record = this.data.record || {};
this.statusEvents = []; this.statusEvents = [];
@@ -114,7 +114,12 @@ class Conference extends Task {
} }
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::*');
this.ep.api(`conference ${this.confName} kick ${this.memberId}`)
.catch((err) => this.logger.info({err}, 'Error kicking participant'));
}
cs.clearConferenceDetails();
this.notifyTaskDone(); this.notifyTaskDone();
} }
@@ -339,9 +344,13 @@ class Conference extends Task {
} }
const opts = {}; const opts = {};
if (this.endConferenceOnExit) Object.assign(opts, {flags: {endconf: true}}); if (this.endConferenceOnExit || this.startConferenceOnEnter || this.joinMuted) {
if (this.startConferenceOnEnter) Object.assign(opts, {flags: {moderator: true}}); Object.assign(opts, {flags: {
if (this.joinMuted) Object.assign(opts, {flags: {mute: true}}); ...(this.endConferenceOnExit && {endconf: true}),
...(this.startConferenceOnEnter && {moderator: true}),
...(this.joinMuted && {joinMuted: true}),
}});
}
try { try {
const {memberId, confUuid} = await this.ep.join(this.confName, opts); const {memberId, confUuid} = await this.ep.join(this.confName, opts);
@@ -384,6 +393,11 @@ class Conference extends Task {
this.ep.api('conference', `${this.confName} set max_members ${this.maxParticipants}`) this.ep.api('conference', `${this.confName} set max_members ${this.maxParticipants}`)
.catch((err) => this.logger.error(err, `Error setting max participants to ${this.maxParticipants}`)); .catch((err) => this.logger.error(err, `Error setting max participants to ${this.maxParticipants}`));
} }
if (typeof this.endConferenceDuration === 'number' && this.endConferenceDuration >= 0) {
this.ep.api('conference', `${this.confName} set endconference_grace_time ${this.endConferenceDuration}`)
.catch((err) => this.logger.error(err, `Error setting end conference time to ${this.endConferenceDuration}`));
}
} }
/** /**

View File

@@ -30,10 +30,18 @@ class TaskConfig extends Task {
if (this.bargeIn[k]) this.gatherOpts[k] = this.bargeIn[k]; if (this.bargeIn[k]) this.gatherOpts[k] = this.bargeIn[k];
}); });
} }
if (this.data.reset) {
if (typeof this.data.reset === 'string') this.data.reset = [this.data.reset];
}
else this.data.reset = [];
if (this.bargeIn.sticky) this.autoEnable = true; if (this.bargeIn.sticky) this.autoEnable = true;
this.preconditions = (this.bargeIn.enable || this.record?.action || this.listen?.url || this.data.amd) ? this.preconditions = (this.bargeIn.enable || this.record?.action || this.listen?.url || this.data.amd) ?
TaskPreconditions.Endpoint : TaskPreconditions.Endpoint :
TaskPreconditions.None; TaskPreconditions.None;
this.onHoldMusic = this.data.onHoldMusic;
} }
get name() { return TaskName.Config; } get name() { return TaskName.Config; }
@@ -45,6 +53,10 @@ class TaskConfig extends Task {
get summary() { get summary() {
const phrase = []; const phrase = [];
/* reset recognizer and/or synthesizer to default values? */
if (this.data.reset.length) phrase.push(`reset ${this.data.reset.join(',')}`);
if (this.bargeIn.enable) phrase.push('enable barge-in'); if (this.bargeIn.enable) phrase.push('enable barge-in');
if (this.hasSynthesizer) { if (this.hasSynthesizer) {
const {vendor:v, language:l, voice} = this.synthesizer; const {vendor:v, language:l, voice} = this.synthesizer;
@@ -62,7 +74,8 @@ class TaskConfig extends Task {
} }
if (this.data.amd) phrase.push('enable amd'); if (this.data.amd) phrase.push('enable amd');
if (this.notifyEvents) phrase.push(`event notification ${this.notifyEvents ? 'on' : 'off'}`); if (this.notifyEvents) phrase.push(`event notification ${this.notifyEvents ? 'on' : 'off'}`);
return `${this.name}{${phrase.join(',')}`; if (this.onHoldMusic) phrase.push(`onHoldMusic: ${this.onHoldMusic}`);
return `${this.name}{${phrase.join(',')}}`;
} }
async exec(cs, {ep} = {}) { async exec(cs, {ep} = {}) {
@@ -73,6 +86,10 @@ class TaskConfig extends Task {
cs.notifyEvents = !!this.data.notifyEvents; cs.notifyEvents = !!this.data.notifyEvents;
} }
if (this.onHoldMusic) {
cs.onHoldMusic = this.onHoldMusic;
}
if (this.data.amd) { if (this.data.amd) {
this.startAmd = cs.startAmd; this.startAmd = cs.startAmd;
this.stopAmd = cs.stopAmd; this.stopAmd = cs.stopAmd;
@@ -86,25 +103,62 @@ class TaskConfig extends Task {
} }
} }
this.data.reset.forEach((k) => {
if (k === 'synthesizer') cs.resetSynthesizer();
else if (k === 'recognizer') cs.resetRecognizer();
});
if (this.hasSynthesizer) { if (this.hasSynthesizer) {
cs.speechSynthesisVendor = this.synthesizer.vendor !== 'default' cs.speechSynthesisVendor = this.synthesizer.vendor !== 'default'
? this.synthesizer.vendor ? this.synthesizer.vendor
: cs.speechSynthesisVendor; : cs.speechSynthesisVendor;
cs.speechSynthesisLabel = this.synthesizer.label !== 'default'
? this.synthesizer.label
: cs.speechSynthesisLabel;
cs.speechSynthesisLanguage = this.synthesizer.language !== 'default' cs.speechSynthesisLanguage = this.synthesizer.language !== 'default'
? this.synthesizer.language ? this.synthesizer.language
: cs.speechSynthesisLanguage; : cs.speechSynthesisLanguage;
cs.speechSynthesisVoice = this.synthesizer.voice !== 'default' cs.speechSynthesisVoice = this.synthesizer.voice !== 'default'
? this.synthesizer.voice ? this.synthesizer.voice
: cs.speechSynthesisVoice; : cs.speechSynthesisVoice;
// fallback vendor
cs.fallbackSpeechSynthesisVendor = this.synthesizer.fallbackVendor !== 'default'
? this.synthesizer.fallbackVendor
: cs.fallbackSpeechSynthesisVendor;
cs.fallbackSpeechSynthesisLabel = this.synthesizer.fallbackLabel !== 'default'
? this.synthesizer.fallbackLabel
: cs.fallbackSpeechSynthesisLabel;
cs.fallbackSpeechSynthesisLanguage = this.synthesizer.fallbackLanguage !== 'default'
? this.synthesizer.fallbackLanguage
: cs.fallbackSpeechSynthesisLanguage;
cs.fallbackSpeechSynthesisVoice = this.synthesizer.fallbackVoice !== 'default'
? this.synthesizer.fallbackVoice
: cs.fallbackSpeechSynthesisVoice;
this.logger.info({synthesizer: this.synthesizer}, 'Config: updated synthesizer'); this.logger.info({synthesizer: this.synthesizer}, 'Config: updated synthesizer');
} }
if (this.hasRecognizer) { if (this.hasRecognizer) {
cs.speechRecognizerVendor = this.recognizer.vendor !== 'default' cs.speechRecognizerVendor = this.recognizer.vendor !== 'default'
? this.recognizer.vendor ? this.recognizer.vendor
: cs.speechRecognizerVendor; : cs.speechRecognizerVendor;
cs.speechRecognizerLabel = this.recognizer.label !== 'default'
? this.recognizer.label
: cs.speechRecognizerLabel;
cs.speechRecognizerLanguage = this.recognizer.language !== 'default' cs.speechRecognizerLanguage = this.recognizer.language !== 'default'
? this.recognizer.language ? this.recognizer.language
: cs.speechRecognizerLanguage; : cs.speechRecognizerLanguage;
//fallback
cs.fallbackSpeechRecognizerVendor = this.recognizer.fallbackVendor !== 'default'
? this.recognizer.fallbackVendor
: cs.fallbackSpeechRecognizerVendor;
cs.fallbackSpeechRecognizerLabel = this.recognizer.fallbackLabel !== 'default'
? this.recognizer.fallbackLabel
: cs.fallbackSpeechRecognizerLabel;
cs.fallbackSpeechRecognizerLanguage = this.recognizer.fallbackLanguage !== 'default'
? this.recognizer.fallbackLanguage
: cs.fallbackSpeechRecognizerLanguage;
cs.isContinuousAsr = typeof this.recognizer.asrTimeout === 'number' ? true : false; cs.isContinuousAsr = typeof this.recognizer.asrTimeout === 'number' ? true : false;
if (cs.isContinuousAsr) { if (cs.isContinuousAsr) {
cs.asrTimeout = this.recognizer.asrTimeout; cs.asrTimeout = this.recognizer.asrTimeout;
@@ -162,6 +216,9 @@ class TaskConfig extends Task {
cs.stopBackgroundListen(); cs.stopBackgroundListen();
} }
} }
if (this.data.sipRequestWithinDialogHook) {
cs.sipRequestWithinDialogHook = this.data.sipRequestWithinDialogHook;
}
} }
async kill(cs) { async kill(cs) {

View File

@@ -16,6 +16,7 @@ class TaskDequeue extends Task {
this.queueName = this.data.name; this.queueName = this.data.name;
this.timeout = this.data.timeout || 0; this.timeout = this.data.timeout || 0;
this.beep = this.data.beep === true; this.beep = this.data.beep === true;
this.callSid = this.data.callSid;
this.emitter = new Emitter(); this.emitter = new Emitter();
this.state = DequeueResults.Timeout; this.state = DequeueResults.Timeout;
@@ -53,7 +54,7 @@ class TaskDequeue extends Task {
} }
_getMemberFromQueue(cs) { _getMemberFromQueue(cs) {
const {popFront} = cs.srf.locals.dbHelpers; const {retrieveFromSortedSet, retrieveByPatternSortedSet} = cs.srf.locals.dbHelpers;
return new Promise(async(resolve) => { return new Promise(async(resolve) => {
let timer; let timer;
@@ -70,7 +71,12 @@ class TaskDequeue extends Task {
do { do {
try { try {
const url = await popFront(this.queueName); let url;
if (this.callSid) {
url = await retrieveByPatternSortedSet(this.queueName, `*${this.callSid}`);
} else {
url = await retrieveFromSortedSet(this.queueName);
}
if (url) { if (url) {
found = true; found = true;
clearTimeout(timer); clearTimeout(timer);
@@ -78,7 +84,7 @@ class TaskDequeue extends Task {
resolve(url); resolve(url);
} }
} catch (err) { } catch (err) {
this.logger.debug({err}, 'TaskDequeue:_getMemberFromQueue error popFront'); this.logger.debug({err}, 'TaskDequeue:_getMemberFromQueue error Sorted Set');
} }
await sleepFor(5000); await sleepFor(5000);
} while (!this.killed && !timedout && !found); } while (!this.killed && !timedout && !found);

View File

@@ -12,9 +12,13 @@ const assert = require('assert');
const placeCall = require('../utils/place-outdial'); const placeCall = require('../utils/place-outdial');
const sessionTracker = require('../session/session-tracker'); const sessionTracker = require('../session/session-tracker');
const DtmfCollector = require('../utils/dtmf-collector'); const DtmfCollector = require('../utils/dtmf-collector');
const ConfirmCallSession = require('../session/confirm-call-session');
const dbUtils = require('../utils/db-utils'); const dbUtils = require('../utils/db-utils');
const debug = require('debug')('jambonz:feature-server'); const debug = require('debug')('jambonz:feature-server');
const {parseUri} = require('drachtio-srf'); const {parseUri} = require('drachtio-srf');
const {ANCHOR_MEDIA_ALWAYS, JAMBONZ_DISABLE_DIAL_PAI_HEADER} = require('../config');
const { isOnhold } = require('../utils/sdp-utils');
const { normalizeJambones } = require('@jambonz/verb-specifications');
function parseDtmfOptions(logger, dtmfCapture) { function parseDtmfOptions(logger, dtmfCapture) {
let parentDtmfCollector, childDtmfCollector; let parentDtmfCollector, childDtmfCollector;
@@ -84,6 +88,7 @@ class TaskDial extends Task {
this.earlyMedia = this.data.answerOnBridge === true; this.earlyMedia = this.data.answerOnBridge === true;
this.callerId = this.data.callerId; this.callerId = this.data.callerId;
this.callerName = this.data.callerName;
this.dialMusic = this.data.dialMusic; this.dialMusic = this.data.dialMusic;
this.headers = this.data.headers || {}; this.headers = this.data.headers || {};
this.method = this.data.method || 'POST'; this.method = this.data.method || 'POST';
@@ -133,11 +138,19 @@ class TaskDial extends Task {
get name() { return TaskName.Dial; } get name() { return TaskName.Dial; }
get isOnHold() {
return this.isIncomingLegHold || this.isOutgoingLegHold;
}
get canReleaseMedia() { get canReleaseMedia() {
return !process.env.ANCHOR_MEDIA_ALWAYS && const keepAnchor = this.data.anchorMedia ||
!this.listenTask && this.cs.isBackGroundListen ||
!this.transcribeTask && ANCHOR_MEDIA_ALWAYS ||
!this.startAmd; this.listenTask ||
this.transcribeTask ||
this.startAmd;
return !keepAnchor;
} }
get summary() { get summary() {
@@ -161,6 +174,16 @@ class TaskDial extends Task {
async exec(cs) { async exec(cs) {
await super.exec(cs); await super.exec(cs);
try { try {
if (this.listenTask) {
const {span, ctx} = this.startChildSpan(`nested:${this.listenTask.summary}`);
this.listenTask.span = span;
this.listenTask.ctx = ctx;
}
if (this.transcribeTask) {
const {span, ctx} = this.startChildSpan(`nested:${this.transcribeTask.summary}`);
this.transcribeTask.span = span;
this.transcribeTask.ctx = ctx;
}
if (this.data.amd) { if (this.data.amd) {
this.startAmd = cs.startAmd; this.startAmd = cs.startAmd;
this.stopAmd = cs.stopAmd; this.stopAmd = cs.stopAmd;
@@ -181,6 +204,7 @@ class TaskDial extends Task {
await this.performAction(this.results, this.killReason !== KillReason.Replaced); await this.performAction(this.results, this.killReason !== KillReason.Replaced);
this._removeDtmfDetection(cs.dlg); this._removeDtmfDetection(cs.dlg);
this._removeDtmfDetection(this.dlg); this._removeDtmfDetection(this.dlg);
this._removeSipIndialogRequestListener(this.dlg);
} catch (err) { } catch (err) {
this.logger.error({err}, 'TaskDial:exec terminating with error'); this.logger.error({err}, 'TaskDial:exec terminating with error');
this.kill(cs); this.kill(cs);
@@ -209,7 +233,7 @@ class TaskDial extends Task {
} }
this._removeDtmfDetection(cs.dlg); this._removeDtmfDetection(cs.dlg);
this._removeDtmfDetection(this.dlg); this._removeDtmfDetection(this.dlg);
this._killOutdials(); await this._killOutdials();
if (this.sd) { if (this.sd) {
this.sd.kill(); this.sd.kill();
this.sd.removeAllListeners(); this.sd.removeAllListeners();
@@ -218,10 +242,12 @@ class TaskDial extends Task {
if (this.callSid) sessionTracker.remove(this.callSid); if (this.callSid) sessionTracker.remove(this.callSid);
if (this.listenTask) { if (this.listenTask) {
await this.listenTask.kill(cs); await this.listenTask.kill(cs);
this.listenTask.span.end();
this.listenTask = null; this.listenTask = null;
} }
if (this.transcribeTask) { if (this.transcribeTask) {
await this.transcribeTask.kill(cs); await this.transcribeTask.kill(cs);
this.transcribeTask.span.end();
this.transcribeTask = null; this.transcribeTask = null;
} }
this.notifyTaskDone(); this.notifyTaskDone();
@@ -302,8 +328,8 @@ class TaskDial extends Task {
sip_refer_to: req.get('Refer-To'), sip_refer_to: req.get('Refer-To'),
sip_referred_by: req.get('Referred-By'), sip_referred_by: req.get('Referred-By'),
sip_user_agent: req.get('User-Agent'), sip_user_agent: req.get('User-Agent'),
refer_to_user: to.user, refer_to_user: to.scheme === 'tel' ? to.number : to.user,
referred_by_user: by.user, referred_by_user: by.scheme === 'tel' ? by.number : by.user,
referring_call_sid, referring_call_sid,
referred_call_sid referred_call_sid
} }
@@ -328,11 +354,16 @@ class TaskDial extends Task {
sd.removeAllListeners('callCreateFail'); sd.removeAllListeners('callCreateFail');
} }
_killOutdials() { async _killOutdials() {
for (const [callSid, sd] of Array.from(this.dials)) { for (const [callSid, sd] of Array.from(this.dials)) {
this.logger.debug(`Dial:_killOutdials killing callSid ${callSid}`); this.logger.debug(`Dial:_killOutdials killing callSid ${callSid}`);
sd.kill().catch((err) => this.logger.info(err, `Dial:_killOutdials Error killing ${callSid}`)); try {
await sd.kill();
} catch (err) {
this.logger.info(err, `Dial:_killOutdials Error killing ${callSid}`);
}
this._removeHandlers(sd); this._removeHandlers(sd);
this.logger.debug(`Dial:_killOutdials killed callSid ${callSid}`);
} }
this.dials.clear(); this.dials.clear();
} }
@@ -345,8 +376,14 @@ class TaskDial extends Task {
} }
_onInfo(cs, dlg, req, res) { _onInfo(cs, dlg, req, res) {
// SIP Indialog will be handled by another handler
if (cs.sipRequestWithinDialogHook) {
return;
}
res.send(200); res.send(200);
if (req.get('Content-Type') !== 'application/dtmf-relay') return; if (req.get('Content-Type') !== 'application/dtmf-relay') {
return;
}
const dtmfDetector = dlg === cs.dlg ? this.parentDtmfCollector : this.childDtmfCollector; const dtmfDetector = dlg === cs.dlg ? this.parentDtmfCollector : this.childDtmfCollector;
if (!dtmfDetector) return; if (!dtmfDetector) return;
@@ -375,6 +412,20 @@ class TaskDial extends Task {
} }
} }
_initSipIndialogRequestListener(cs, dlg) {
dlg.on('info', this._onRequestWithinDialog.bind(this, cs));
dlg.on('message', this._onRequestWithinDialog.bind(this, cs));
}
_removeSipIndialogRequestListener(dlg) {
dlg && dlg.removeAllListeners('message');
dlg && dlg.removeAllListeners('info');
}
async _onRequestWithinDialog(cs, req, res) {
cs._onRequestWithinDialog(req, res);
}
async _initializeInbound(cs) { async _initializeInbound(cs) {
const {ep} = await cs._evalEndpointPrecondition(this); const {ep} = await cs._evalEndpointPrecondition(this);
this.epOther = ep; this.epOther = ep;
@@ -394,20 +445,27 @@ class TaskDial extends Task {
const {req, srf} = cs; const {req, srf} = cs;
const {getSBC} = srf.locals; const {getSBC} = srf.locals;
const {lookupTeamsByAccount, lookupAccountBySid} = srf.locals.dbHelpers; const {lookupTeamsByAccount, lookupAccountBySid} = srf.locals.dbHelpers;
const {lookupCarrier} = dbUtils(this.logger, cs.srf); const {lookupCarrier, lookupCarrierByPhoneNumber} = dbUtils(this.logger, cs.srf);
const sbcAddress = this.proxy || getSBC(); const sbcAddress = this.proxy || getSBC();
const teamsInfo = {}; const teamsInfo = {};
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');
const opts = { this.headers = {
headers: req && req.has('X-CID') ? Object.assign(this.headers, {'X-CID': req.get('X-CID')}) : this.headers, 'X-Account-Sid': cs.accountSid,
proxy: `sip:${sbcAddress}`, ...(req && req.has('X-CID') && {'X-CID': req.get('X-CID')}),
callingNumber: this.callerId || req.callingNumber ...(req && req.has('P-Asserted-Identity') && !JAMBONZ_DISABLE_DIAL_PAI_HEADER &&
{'P-Asserted-Identity': req.get('P-Asserted-Identity')}),
...(req && req.has('X-Voip-Carrier-Sid') && {'X-Voip-Carrier-Sid': req.get('X-Voip-Carrier-Sid')}),
// Put headers at the end to make sure opt.headers override all default behavior.
...this.headers
}; };
opts.headers = {
...opts.headers, const opts = {
'X-Account-Sid': cs.accountSid headers: this.headers,
proxy: `sip:${sbcAddress}`,
callingNumber: this.callerId || req.callingNumber,
...(this.callerName && {callingName: this.callerName})
}; };
const t = this.target.find((t) => t.type === 'teams'); const t = this.target.find((t) => t.type === 'teams');
@@ -418,10 +476,14 @@ class TaskDial extends Task {
} }
const ms = await cs.getMS(); const ms = await cs.getMS();
this.timerRing = setTimeout(() => { this.timerRing = setTimeout(async() => {
this.logger.info(`Dial:_attemptCall: ring no answer timer ${this.timeout}s exceeded`); this.logger.info(`Dial:_attemptCall: ring no answer timer ${this.timeout}s exceeded`);
this.timerRing = null; this.timerRing = null;
this._killOutdials(); try {
await this._killOutdials();
} catch (err) {
this.logger.info(err, 'Dial:_attemptCall - error killing outdials');
}
this.result = { this.result = {
dialCallStatus: CallStatus.NoAnswer, dialCallStatus: CallStatus.NoAnswer,
dialSipStatus: 487 dialSipStatus: 487
@@ -449,7 +511,22 @@ class TaskDial extends Task {
} }
if (t.type === 'phone' && t.trunk) { if (t.type === 'phone' && t.trunk) {
const voip_carrier_sid = await lookupCarrier(cs.accountSid, t.trunk); const voip_carrier_sid = await lookupCarrier(cs.accountSid, t.trunk);
this.logger.info(`Dial:_attemptCalls: selected ${voip_carrier_sid} for requested carrier: ${t.trunk})`); this.logger.info(`Dial:_attemptCalls: selected ${voip_carrier_sid} for requested carrier: ${t.trunk}`);
if (voip_carrier_sid) {
opts.headers['X-Requested-Carrier-Sid'] = voip_carrier_sid;
}
}
/**
* trunk isn't specified,
* check if number matches any existing numbers
* */
if (t.type === 'phone' && !t.trunk) {
const str = this.callerId || req.callingNumber || '';
const callingNumber = str.startsWith('+') ? str.substring(1) : str;
const voip_carrier_sid = await lookupCarrierByPhoneNumber(cs.accountSid, callingNumber);
this.logger.info(
`Dial:_attemptCalls: selected ${voip_carrier_sid} for requested phone number: ${callingNumber}`);
if (voip_carrier_sid) { if (voip_carrier_sid) {
opts.headers['X-Requested-Carrier-Sid'] = voip_carrier_sid; opts.headers['X-Requested-Carrier-Sid'] = voip_carrier_sid;
} }
@@ -468,7 +545,8 @@ class TaskDial extends Task {
callInfo: cs.callInfo, callInfo: cs.callInfo,
accountInfo: cs.accountInfo, accountInfo: cs.accountInfo,
rootSpan: cs.rootSpan, rootSpan: cs.rootSpan,
startSpan: this.startSpan.bind(this) startSpan: this.startSpan.bind(this),
dialTask: this
}); });
this.dials.set(sd.callSid, sd); this.dials.set(sd.callSid, sd);
@@ -484,7 +562,8 @@ class TaskDial extends Task {
} }
}) })
.on('callStatusChange', (obj) => { .on('callStatusChange', (obj) => {
if (this.results.dialCallStatus !== CallStatus.Completed) { if (this.results.dialCallStatus !== CallStatus.Completed &&
this.results.dialCallStatus !== CallStatus.NoAnswer) {
Object.assign(this.results, { Object.assign(this.results, {
dialCallStatus: obj.callStatus, dialCallStatus: obj.callStatus,
dialSipStatus: obj.sipStatus, dialSipStatus: obj.sipStatus,
@@ -537,11 +616,7 @@ class TaskDial extends Task {
} }
}) })
.on('reinvite', (req, res) => { .on('reinvite', (req, res) => {
try { this._onReinvite(req, res);
cs.handleReinviteAfterMediaReleased(req, res);
} catch (err) {
this.logger.error(err, 'Error in dial einvite from B leg');
}
}) })
.on('refer', (callInfo, req, res) => { .on('refer', (callInfo, req, res) => {
@@ -577,6 +652,35 @@ class TaskDial extends Task {
this._killOutdials(); // NB: order is important this._killOutdials(); // NB: order is important
} }
async _onReinvite(req, res) {
try {
let isHandled = false;
if (this.cs.onHoldMusic) {
if (isOnhold(req.body) && !this.epOther && !this.ep) {
await this.cs.handleReinviteAfterMediaReleased(req, res);
// Onhold but media is already released
// reconnect A Leg and Response B leg
await this.reAnchorMedia(this.cs, this.sd);
this.isOutgoingLegHold = true;
isHandled = true;
this._onHoldHook();
} else if (!isOnhold(req.body) && this.epOther && this.ep && this.isOutgoingLegHold && this.canReleaseMedia) {
// Offhold, time to release media
const newSdp = await this.ep.modify(req.body);
await res.send(200, {body: newSdp});
await this._releaseMedia(this.cs, this.sd);
isHandled = true;
this.isOutgoingLegHold = false;
}
}
if (!isHandled) {
this.cs.handleReinviteAfterMediaReleased(req, res);
}
} catch (err) {
this.logger.error(err, 'Error in dial einvite from B leg');
}
}
_onMaxCallDuration(cs) { _onMaxCallDuration(cs) {
this.logger.info(`Dial:_onMaxCallDuration tearing down call as it has reached ${this.timeLimit}s`); this.logger.info(`Dial:_onMaxCallDuration tearing down call as it has reached ${this.timeLimit}s`);
this.ep && this.ep.unbridge(); this.ep && this.ep.unbridge();
@@ -629,8 +733,9 @@ 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 (cs.sipRequestWithinDialogHook) this._initSipIndialogRequestListener(cs, this.dlg);
if (this.transcribeTask) this.transcribeTask.exec(cs, {ep2: this.epOther, ep:this.ep}); if (this.transcribeTask) this.transcribeTask.exec(cs, {ep: this.epOther, ep2:this.ep});
if (this.listenTask) this.listenTask.exec(cs, {ep: this.epOther}); if (this.listenTask) this.listenTask.exec(cs, {ep: this.epOther});
if (this.startAmd) { if (this.startAmd) {
try { try {
@@ -681,9 +786,29 @@ class TaskDial extends Task {
} }
async handleReinviteAfterMediaReleased(req, res) { async handleReinviteAfterMediaReleased(req, res) {
const sdp = await this.dlg.modify(req.body); let isHandled = false;
this.logger.info({sdp}, 'Dial:handleReinviteAfterMediaReleased - sent reinvite to B leg'); if (isOnhold(req.body) && !this.epOther && !this.ep) {
res.send(200, {body: sdp}); const sdp = await this.dlg.modify(req.body);
res.send(200, {body: sdp});
// Onhold but media is already released
await this.reAnchorMedia(this.cs, this.sd);
isHandled = true;
this.isIncomingLegHold = true;
this._onHoldHook();
} else if (!isOnhold(req.body) && this.epOther && this.ep && this.isIncomingLegHold && this.canReleaseMedia) {
// Offhold, time to release media
const newSdp = await this.epOther.modify(req.body);
await res.send(200, {body: newSdp});
await this._releaseMedia(this.cs, this.sd);
isHandled = true;
this.isIncomingLegHold = false;
}
if (!isHandled) {
const sdp = await this.dlg.modify(req.body);
this.logger.info({sdp}, 'Dial:handleReinviteAfterMediaReleased - sent reinvite to B leg');
res.send(200, {body: sdp});
}
} }
_onAmdEvent(cs, evt) { _onAmdEvent(cs, evt) {
@@ -694,6 +819,48 @@ class TaskDial extends Task {
this.logger.error({err}, 'Dial:_onAmdEvent - error calling actionHook'); this.logger.error({err}, 'Dial:_onAmdEvent - error calling actionHook');
}); });
} }
async _onHoldHook(allowed = [TaskName.Play, TaskName.Say, TaskName.Pause]) {
if (this.data.onHoldHook) {
// send silence for keep Voice quality
await this.epOther.play('silence_stream://500');
let allowedTasks;
do {
try {
const b3 = this.getTracingPropagation();
const httpHeaders = b3 && {b3};
const json = await this.cs.application.requestor.
request('verb:hook', this.data.onHoldHook, this.cs.callInfo.toJSON(), httpHeaders);
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
allowedTasks = tasks.filter((t) => allowed.includes(t.name));
if (tasks.length !== allowedTasks.length) {
this.logger.debug({tasks, allowedTasks}, 'unsupported task');
throw new Error(`unsupported verb in enqueue waitHook: only ${JSON.stringify(allowed)}`);
}
this.logger.debug(`DialTask:_onHoldHook: executing ${tasks.length} tasks`);
if (tasks.length) {
this._playSession = new ConfirmCallSession({
logger: this.logger,
application: this.cs.application,
dlg: this.isIncomingLegHold ? this.dlg : this.cs.dlg,
ep: this.isIncomingLegHold ? this.ep : this.cs.ep,
callInfo: this.cs.callInfo,
accountInfo: this.cs.accountInfo,
tasks,
rootSpan: this.cs.rootSpan
});
await this._playSession.exec();
this._playSession = null;
}
} catch (error) {
this.logger.info(error, 'DialTask:_onHoldHook: failed retrieving waitHook');
this._playSession = null;
break;
}
} while (allowedTasks && allowedTasks.length > 0 && !this.killed && this.isOnHold);
this.logger.info('Finish onHoldHook');
}
}
} }
module.exports = TaskDial; module.exports = TaskDial;

View File

@@ -58,6 +58,13 @@ class Dialogflow extends Task {
this.vendor = this.data.tts.vendor || 'default'; this.vendor = this.data.tts.vendor || 'default';
this.language = this.data.tts.language || 'default'; this.language = this.data.tts.language || 'default';
this.voice = this.data.tts.voice || 'default'; this.voice = this.data.tts.voice || 'default';
this.speechSynthesisLabel = this.data.tts.label || 'default';
// fallback tts
this.fallbackVendor = this.data.tts.fallbackVendor || 'default';
this.fallbackLanguage = this.data.tts.fallbackLanguage || 'default';
this.fallbackVoice = this.data.tts.fallbackLanguage || 'default';
this.fallbackLabel = this.data.tts.fallbackLabel || 'default';
} }
this.bargein = this.data.bargein; this.bargein = this.data.bargein;
} }
@@ -118,8 +125,15 @@ class Dialogflow extends Task {
this.vendor = cs.speechSynthesisVendor; this.vendor = cs.speechSynthesisVendor;
this.language = cs.speechSynthesisLanguage; this.language = cs.speechSynthesisLanguage;
this.voice = cs.speechSynthesisVoice; this.voice = cs.speechSynthesisVoice;
this.speechSynthesisLabel = cs.speechSynthesisLabel;
} }
this.ttsCredentials = cs.getSpeechCredentials(this.vendor, 'tts'); if (this.fallbackVendor === 'default') {
this.fallbackVendor = cs.fallbackSpeechSynthesisVendor;
this.fallbackLanguage = cs.fallbackSpeechSynthesisLanguage;
this.fallbackVoice = cs.fallbackSpeechSynthesisVoice;
this.fallbackLabel = cs.fallbackSpeechSynthesisLabel;
}
this.ttsCredentials = cs.getSpeechCredentials(this.vendor, 'tts', this.speechSynthesisLabel);
this.ep.addCustomEventListener('dialogflow::intent', this._onIntent.bind(this, ep, cs)); this.ep.addCustomEventListener('dialogflow::intent', this._onIntent.bind(this, ep, cs));
this.ep.addCustomEventListener('dialogflow::transcription', this._onTranscription.bind(this, ep, cs)); this.ep.addCustomEventListener('dialogflow::transcription', this._onTranscription.bind(this, ep, cs));
@@ -221,18 +235,8 @@ class Dialogflow extends Task {
} }
try { try {
const obj = { const {filePath} = await this._fallbackSynthAudio(cs, intent, stats, synthAudio);
text: intent.fulfillmentText,
vendor: this.vendor,
language: this.language,
voice: this.voice,
salt: cs.callSid,
credentials: this.ttsCredentials
};
this.logger.debug({obj}, 'Dialogflow:_onIntent - playing message via tts');
const {filePath, servedFromCache} = await synthAudio(stats, obj);
if (filePath) cs.trackTmpFile(filePath); if (filePath) cs.trackTmpFile(filePath);
if (!this.ttsCredentials && !servedFromCache) cs.billForTts(intent.fulfillmentText.length);
if (this.playInProgress) { if (this.playInProgress) {
await ep.api('uuid_break', ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio')); await ep.api('uuid_break', ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
@@ -276,6 +280,46 @@ class Dialogflow extends Task {
} }
} }
async _fallbackSynthAudio(cs, intent, stats, synthAudio) {
try {
const obj = {
account_sid: cs.accountSid,
text: intent.fulfillmentText,
vendor: this.vendor,
language: this.language,
voice: this.voice,
salt: cs.callSid,
credentials: this.ttsCredentials
};
this.logger.debug({obj}, 'Dialogflow:_onIntent - playing message via tts');
return await synthAudio(stats, obj);
} catch (error) {
this.logger.info({error}, 'Failed to synthesize audio from primary vendor');
try {
if (this.fallbackVendor) {
const credentials = cs.getSpeechCredentials(this.fallbackVendor, 'tts', this.fallbackLabel);
const obj = {
account_sid: cs.accountSid,
text: intent.fulfillmentText,
vendor: this.fallbackVendor,
language: this.fallbackLanguage,
voice: this.fallbackVoice,
salt: cs.callSid,
credentials
};
this.logger.debug({obj}, 'Dialogflow:_onIntent - playing message via fallback tts');
return await synthAudio(stats, obj);
}
} catch (err) {
this.logger.info({err}, 'Failed to synthesize audio from falllback vendor');
throw err;
}
throw error;
}
}
/** /**
* A transcription - either interim or final - has been returned. * A transcription - either interim or final - has been returned.
* If we are doing barge-in based on hotword detection, check for the hotword or phrase. * If we are doing barge-in based on hotword detection, check for the hotword or phrase.

View File

@@ -18,6 +18,7 @@ class TaskEnqueue extends Task {
this.preconditions = TaskPreconditions.Endpoint; this.preconditions = TaskPreconditions.Endpoint;
this.queueName = this.data.name; this.queueName = this.data.name;
this.priority = this.data.priority;
this.waitHook = this.data.waitHook; this.waitHook = this.data.waitHook;
this.emitter = new Emitter(); this.emitter = new Emitter();
@@ -70,12 +71,22 @@ class TaskEnqueue extends Task {
} }
async _addToQueue(cs, dlg) { async _addToQueue(cs, dlg) {
const {pushBack} = cs.srf.locals.dbHelpers; const {addToSortedSet, sortedSetLength} = cs.srf.locals.dbHelpers;
const url = getUrl(cs); const url = getUrl(cs);
this.waitStartTime = Date.now(); this.waitStartTime = Date.now();
this.logger.debug({queue: this.queueName, url}, 'pushing url onto queue'); this.logger.debug({queue: this.queueName, url}, 'pushing url onto queue');
const members = await pushBack(this.queueName, url); if (this.priority < 0) {
this.logger.info(`TaskEnqueue:_addToQueue: added to queue, length now ${members}`); this.logger.warn(`priority ${this.priority} is invalid, need to be non-negative integer,
999 will be used for priority`);
}
let members = await addToSortedSet(this.queueName, url, this.priority);
if (members === 1) {
this.logger.info('TaskEnqueue:_addToQueue: added to queue');
} else {
this.logger.info('TaskEnqueue:_addToQueue: failed to add to queue');
}
members = await sortedSetLength(this.queueName);
this.notifyUrl = url; this.notifyUrl = url;
/* invoke account-level webhook for queue event notifications */ /* invoke account-level webhook for queue event notifications */
@@ -90,9 +101,9 @@ class TaskEnqueue extends Task {
} }
async _removeFromQueue(cs) { async _removeFromQueue(cs) {
const {removeFromList, lengthOfList} = cs.srf.locals.dbHelpers; const {retrieveByPatternSortedSet, sortedSetLength} = cs.srf.locals.dbHelpers;
await removeFromList(this.queueName, getUrl(cs)); await retrieveByPatternSortedSet(this.queueName, `*${getUrl(cs)}`);
return await lengthOfList(this.queueName); return await sortedSetLength(this.queueName);
} }
async performAction() { async performAction() {
@@ -279,13 +290,13 @@ class TaskEnqueue extends Task {
this.emitter.emit('dequeue', opts); this.emitter.emit('dequeue', opts);
try { try {
const {lengthOfList} = cs.srf.locals.dbHelpers; const {sortedSetLength} = cs.srf.locals.dbHelpers;
const members = await lengthOfList(this.queueName); const members = await sortedSetLength(this.queueName);
this.dequeued = true; this.dequeued = true;
cs.performQueueWebhook({ cs.performQueueWebhook({
event: 'leave', event: 'leave',
queue: this.data.name, queue: this.data.name,
length: Math.max(members - 1, 0), length: Math.max(members, 0),
leaveReason: 'dequeued', leaveReason: 'dequeued',
leaveTime: Date.now(), leaveTime: Date.now(),
dequeuer: opts.dequeuer dequeuer: opts.dequeuer
@@ -301,7 +312,7 @@ class TaskEnqueue extends Task {
} }
async _playHook(cs, dlg, hook, allowed = [TaskName.Play, TaskName.Say, TaskName.Pause, TaskName.Leave]) { async _playHook(cs, dlg, hook, allowed = [TaskName.Play, TaskName.Say, TaskName.Pause, TaskName.Leave]) {
const {lengthOfList, getListPosition} = cs.srf.locals.dbHelpers; const {sortedSetLength, sortedSetPositionByPattern} = cs.srf.locals.dbHelpers;
const b3 = this.getTracingPropagation(); const b3 = this.getTracingPropagation();
const httpHeaders = b3 && {b3}; const httpHeaders = b3 && {b3};
@@ -313,9 +324,14 @@ class TaskEnqueue extends Task {
queueTime: getElapsedTime(this.waitStartTime) queueTime: getElapsedTime(this.waitStartTime)
}; };
try { try {
const queueSize = await lengthOfList(this.queueName); const queueSize = await sortedSetLength(this.queueName);
const queuePosition = await getListPosition(this.queueName, this.notifyUrl); const queuePosition = await sortedSetPositionByPattern(this.queueName, `*${this.notifyUrl}`);
Object.assign(params, {queueSize, queuePosition}); Object.assign(params, {
queueSize,
queuePosition: queuePosition.length ? queuePosition[0] : 0,
callSid: this.cs.callSid,
callId: this.cs.callId,
});
} catch (err) { } catch (err) {
this.logger.error({err}, `TaskEnqueue:_playHook error retrieving list info for queue ${this.queueName}`); this.logger.error({err}, `TaskEnqueue:_playHook error retrieving list info for queue ${this.queueName}`);
} }

View File

@@ -1,18 +1,24 @@
const Task = require('./task');
const { const {
TaskName, TaskName,
TaskPreconditions,
GoogleTranscriptionEvents, GoogleTranscriptionEvents,
NuanceTranscriptionEvents, NuanceTranscriptionEvents,
AwsTranscriptionEvents, AwsTranscriptionEvents,
AzureTranscriptionEvents, AzureTranscriptionEvents,
DeepgramTranscriptionEvents, DeepgramTranscriptionEvents,
SonioxTranscriptionEvents,
CobaltTranscriptionEvents,
IbmTranscriptionEvents, IbmTranscriptionEvents,
NvidiaTranscriptionEvents NvidiaTranscriptionEvents,
JambonzTranscriptionEvents
} = require('../utils/constants'); } = require('../utils/constants');
const {
JAMBONES_GATHER_EARLY_HINTS_MATCH,
JAMBONZ_GATHER_EARLY_HINTS_MATCH,
JAMBONES_GATHER_CLEAR_GLOBAL_HINTS_ON_EMPTY_HINTS,
} = require('../config');
const makeTask = require('./make_task'); const makeTask = require('./make_task');
const assert = require('assert'); const assert = require('assert');
const SttTask = require('./stt-task');
const compileTranscripts = (logger, evt, arr) => { const compileTranscripts = (logger, evt, arr) => {
if (!Array.isArray(arr) || arr.length === 0) return; if (!Array.isArray(arr) || arr.length === 0) return;
@@ -24,21 +30,9 @@ const compileTranscripts = (logger, evt, arr) => {
evt.alternatives[0].transcript = t.trim(); evt.alternatives[0].transcript = t.trim();
}; };
class TaskGather extends Task { class TaskGather extends SttTask {
constructor(logger, opts, parentTask) { constructor(logger, opts, parentTask) {
super(logger, opts); super(logger, opts, parentTask);
this.preconditions = TaskPreconditions.Endpoint;
const {
setChannelVarsForStt,
normalizeTranscription,
removeSpeechListeners,
setSpeechCredentialsAtRuntime
} = require('../utils/transcription-utils')(logger);
this.setChannelVarsForStt = setChannelVarsForStt;
this.normalizeTranscription = normalizeTranscription;
this.removeSpeechListeners = removeSpeechListeners;
[ [
'finishOnKey', 'input', 'numDigits', 'minDigits', 'maxDigits', 'finishOnKey', 'input', 'numDigits', 'minDigits', 'maxDigits',
'interDigitTimeout', 'partialResultHook', 'bargein', 'dtmfBargein', 'interDigitTimeout', 'partialResultHook', 'bargein', 'dtmfBargein',
@@ -50,26 +44,25 @@ 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;
this.vendor = recognizer.vendor;
this.language = recognizer.language;
/* let credentials be supplied in the recognizer object at runtime */
this.sttCredentials = setSpeechCredentialsAtRuntime(recognizer);
/* continuous ASR (i.e. compile transcripts until a special timeout or dtmf key) */ /* 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 this.data.recognizer.asrTimeout === 'number' ?
if (this.asrTimeout > 0) this.asrDtmfTerminationDigit = recognizer.asrDtmfTerminationDigit; this.data.recognizer.asrTimeout * 1000 : 0;
if (this.asrTimeout > 0) this.asrDtmfTerminationDigit = this.data.recognizer.asrDtmfTerminationDigit;
this.isContinuousAsr = this.asrTimeout > 0; this.isContinuousAsr = this.asrTimeout > 0;
this.data.recognizer.hints = this.data.recognizer.hints || []; if (Array.isArray(this.data.recognizer.hints) &&
this.data.recognizer.altLanguages = this.data.recognizer.altLanguages || []; 0 == this.data.recognizer.hints.length && JAMBONES_GATHER_CLEAR_GLOBAL_HINTS_ON_EMPTY_HINTS) {
logger.debug('Gather: an empty hints array was supplied, so we will mask global hints');
this.maskGlobalSttHints = true;
}
// fast Recognition, fire event after a specified time after the last hypothesis.
this.fastRecognitionTimeout = typeof this.data.recognizer.fastRecognitionTimeout === 'number' ?
this.data.recognizer.fastRecognitionTimeout * 1000 : 0;
} }
else this.data.recognizer = {hints: [], altLanguages: []};
this.digitBuffer = ''; this.digitBuffer = '';
this._earlyMedia = this.data.earlyMedia === true; this._earlyMedia = this.data.earlyMedia === true;
@@ -84,14 +77,17 @@ class TaskGather extends Task {
/* buffer speech for continuous asr */ /* buffer speech for continuous asr */
this._bufferedTranscripts = []; this._bufferedTranscripts = [];
this.partialTranscriptsCount = 0;
this.parentTask = parentTask;
} }
get name() { return TaskName.Gather; } get name() { return TaskName.Gather; }
get needsStt() { return this.input.includes('speech'); } get needsStt() { return this.input.includes('speech'); }
get wantsSingleUtterance() {
return this.data.recognizer?.singleUtterance === true;
}
get earlyMedia() { get earlyMedia() {
return (this.sayTask && this.sayTask.earlyMedia) || return (this.sayTask && this.sayTask.earlyMedia) ||
(this.playTask && this.playTask.earlyMedia); (this.playTask && this.playTask.earlyMedia);
@@ -113,21 +109,23 @@ class TaskGather extends Task {
} }
async exec(cs, {ep}) { async exec(cs, {ep}) {
this.logger.debug('Gather:exec'); this.logger.debug({options: this.data}, '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) { if (cs.hasGlobalSttHints && !this.maskGlobalSttHints) {
const {hints, hintsBoost} = cs.globalSttHints; const {hints, hintsBoost} = cs.globalSttHints;
this.data.recognizer.hints = this.data.recognizer.hints.concat(hints); 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; if (!this.data.recognizer.hintsBoost && hintsBoost) this.data.recognizer.hintsBoost = hintsBoost;
this.logger.debug({hints: this.data.recognizer.hints, hintsBoost: this.data.recognizer.hintsBoost}, this.logger.debug({hints: this.data.recognizer.hints, hintsBoost: this.data.recognizer.hintsBoost},
'Gather:exec - applying global sttHints'); 'Gather:exec - applying global sttHints');
} }
if (cs.hasAltLanguages) { if (cs.hasAltLanguages) {
this.data.recognizer.altLanguages = this.data.recognizer.altLanguages.concat(cs.altLanguages); this.data.recognizer.altLanguages = this.data.recognizer.altLanguages.concat(cs.altLanguages);
this.logger.debug({altLanguages: this.altLanguages}, this.logger.debug({altLanguages: this.data.recognizer?.altLanguages},
'Gather:exec - applying altLanguages'); 'Gather:exec - applying altLanguages');
} }
if (cs.hasGlobalSttPunctuation && !this.data.recognizer.punctuation) { if (cs.hasGlobalSttPunctuation && !this.data.recognizer.punctuation) {
@@ -142,7 +140,8 @@ 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');
} }
if (process.env.JAMBONZ_GATHER_EARLY_HINTS_MATCH && this.needsStt &&
if ((JAMBONZ_GATHER_EARLY_HINTS_MATCH || JAMBONES_GATHER_EARLY_HINTS_MATCH) && this.needsStt &&
!this.isContinuousAsr && !this.isContinuousAsr &&
this.data.recognizer?.hints?.length > 0 && this.data.recognizer?.hints?.length <= 10) { this.data.recognizer?.hints?.length > 0 && this.data.recognizer?.hints?.length <= 10) {
this.earlyHintsMatch = true; this.earlyHintsMatch = true;
@@ -159,59 +158,69 @@ class TaskGather extends Task {
this.language = cs.speechRecognizerLanguage; this.language = cs.speechRecognizerLanguage;
if (this.data.recognizer) this.data.recognizer.language = this.language; if (this.data.recognizer) this.data.recognizer.language = this.language;
} }
if ('default' === this.label || !this.label) {
this.label = cs.speechRecognizerLabel;
if (this.data.recognizer) this.data.recognizer.label = this.label;
}
// Fallback options
if ('default' === this.fallbackVendor || !this.fallbackVendor) {
this.fallbackVendor = cs.fallbackSpeechRecognizerVendor;
if (this.data.recognizer) this.data.recognizer.fallbackVendor = this.fallbackVendor;
}
if ('default' === this.fallbackLanguage || !this.fallbackLanguage) {
this.fallbackLanguage = cs.fallbackSpeechRecognizerLanguage;
if (this.data.recognizer) this.data.recognizer.fallbackLanguage = this.fallbackLanguage;
}
if ('default' === this.fallbackLabel || !this.fallbackLabel) {
this.fallbackLabel = cs.fallbackSpeechRecognizerLabel;
if (this.data.recognizer) this.data.recognizer.fallbackLabel = this.fallbackLabel;
}
if (!this.data.recognizer.vendor) { if (!this.data.recognizer.vendor) {
this.data.recognizer.vendor = this.vendor; this.data.recognizer.vendor = this.vendor;
} }
if (this.needsStt && !this.sttCredentials) this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt'); if (this.vendor === 'cobalt' && !this.data.recognizer.model) {
if (this.needsStt && !this.sttCredentials) { // By default, application saves cobalt model in language
const {writeAlerts, AlertType} = cs.srf.locals; this.data.recognizer.model = cs.speechRecognizerLanguage;
this.logger.info(`TaskGather:exec - ERROR stt using ${this.vendor} requested but creds not supplied`);
writeAlerts({
account_sid: cs.accountSid,
alert_type: AlertType.STT_NOT_PROVISIONED,
vendor: this.vendor
}).catch((err) => this.logger.info({err}, 'Error generating alert for no stt'));
// Notify application that STT vender is wrong.
this.notifyError({
msg: 'ASR error',
details: `No speech-to-text service credentials for ${this.vendor} have been configured`
});
this.notifyTaskDone();
throw new Error(`No speech-to-text service credentials for ${this.vendor} have been configured`);
} }
this.logger.info({sttCredentials: this.sttCredentials}, 'Gather:exec - sttCredentials'); if (this.needsStt && !this.sttCredentials) {
if (this.vendor === 'nuance' && this.sttCredentials.client_id) { try {
/* get nuance access token */ this.sttCredentials = await this._initSpeechCredentials(cs, this.vendor, this.label);
const {client_id, secret} = this.sttCredentials; } catch (error) {
const {access_token, servedFromCache} = await getNuanceAccessToken(client_id, secret, 'asr tts'); if (this.fallbackVendor && this.isHandledByPrimaryProvider) {
this.logger.debug({client_id}, `Gather:exec - got nuance access token ${servedFromCache ? 'from cache' : ''}`); await this._fallback();
this.sttCredentials = {...this.sttCredentials, access_token}; } else {
throw error;
}
}
} }
else if (this.vendor == 'ibm' && this.sttCredentials.stt_api_key) {
/* get ibm access token */ /* when using cobalt model is required */
const {stt_api_key, stt_region} = this.sttCredentials; if (this.vendor === 'cobalt' && !this.data.recognizer.model) {
const {access_token, servedFromCache} = await getIbmAccessToken(stt_api_key); this.notifyError({ msg: 'ASR error', details:'Cobalt requires a model to be specified'});
this.logger.debug({stt_api_key}, `Gather:exec - got ibm access token ${servedFromCache ? 'from cache' : ''}`); throw new Error('Cobalt requires a model to be specified');
this.sttCredentials = {...this.sttCredentials, access_token, stt_region};
} }
const startListening = (cs, ep) => {
const startListening = async(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.logger.debug('Gather:exec - calling _initSpeech'); try {
this._initSpeech(cs, ep) await this._setSpeechHandlers(cs, ep);
.then(() => { if (this.killed) {
if (this.killed) { this.logger.info('Gather:exec - task was quickly killed so do not transcribe');
this.logger.info('Gather:exec - task was quickly killed so do not transcribe'); return;
return; }
} this._startTranscribing(ep);
this._startTranscribing(ep); return updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid);
return updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid); } catch (e) {
}) if (this.fallbackVendor && this.isHandledByPrimaryProvider) {
.catch((err) => { await this._fallback();
this.logger.error({err}, 'error in initSpeech'); startListening(cs, ep);
}); } else {
this.logger.error({error: e}, 'error in initSpeech');
}
}
} }
}; };
@@ -265,7 +274,7 @@ class TaskGather extends Task {
} }
if (this.input.includes('speech') && this.listenDuringPrompt) { if (this.input.includes('speech') && this.listenDuringPrompt) {
await this._initSpeech(cs, ep); await this._setSpeechHandlers(cs, ep);
this._startTranscribing(ep); this._startTranscribing(ep);
updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid) updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid)
.catch(() => {/*already logged error */}); .catch(() => {/*already logged error */});
@@ -287,6 +296,7 @@ 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');
@@ -300,6 +310,7 @@ class TaskGather extends Task {
const {timeout} = opts; const {timeout} = opts;
this.timeout = timeout; this.timeout = timeout;
this._startTimer(); this._startTimer();
return true;
} }
_onDtmf(cs, ep, evt) { _onDtmf(cs, ep, evt) {
@@ -337,9 +348,10 @@ class TaskGather extends Task {
} }
} }
async _initSpeech(cs, ep) { async _setSpeechHandlers(cs, ep) {
if (this._speechHandlersSet) return;
this._speechHandlersSet = true;
const opts = this.setChannelVarsForStt(this, this.sttCredentials, this.data.recognizer); const opts = this.setChannelVarsForStt(this, this.sttCredentials, this.data.recognizer);
this.logger.debug(opts, 'TaskGather:_initSpeech - channel vars');
switch (this.vendor) { switch (this.vendor) {
case 'google': case 'google':
this.bugname = 'google_transcribe'; this.bugname = 'google_transcribe';
@@ -371,8 +383,6 @@ class TaskGather extends Task {
this._onTranscriptionComplete.bind(this, cs, ep)); this._onTranscriptionComplete.bind(this, cs, ep));
ep.addCustomEventListener(NuanceTranscriptionEvents.VadDetected, ep.addCustomEventListener(NuanceTranscriptionEvents.VadDetected,
this._onVadDetected.bind(this, cs, ep)); this._onVadDetected.bind(this, cs, ep));
ep.addCustomEventListener(NuanceTranscriptionEvents.Error,
this._onNuanceError.bind(this, cs, ep));
/* stall timers until prompt finishes playing */ /* stall timers until prompt finishes playing */
if ((this.sayTask || this.playTask) && this.listenDuringPrompt) { if ((this.sayTask || this.playTask) && this.listenDuringPrompt) {
@@ -388,14 +398,47 @@ class TaskGather extends Task {
this._onDeepGramConnectFailure.bind(this, cs, ep)); this._onDeepGramConnectFailure.bind(this, cs, ep));
break; break;
case 'soniox':
this.bugname = 'soniox_transcribe';
ep.addCustomEventListener(SonioxTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
break;
case 'cobalt':
this.bugname = 'cobalt_transcribe';
ep.addCustomEventListener(CobaltTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
/* cobalt doesnt have language, it has model, which is required */
if (!this.data.recognizer.model) {
throw new Error('Cobalt requires a model to be specified');
}
this.language = this.data.recognizer.model;
/* special case: if using hints with cobalt we need to compile them */
this.hostport = opts.COBALT_SERVER_URI;
if (this.vendor === 'cobalt' && opts.COBALT_SPEECH_HINTS) {
try {
const context = await this.compileHintsForCobalt(
ep,
this.hostport,
this.data.recognizer.model,
opts.COBALT_CONTEXT_TOKEN,
opts.COBALT_SPEECH_HINTS
);
if (context) opts.COBALT_COMPILED_CONTEXT_DATA = context;
delete opts.COBALT_SPEECH_HINTS;
} catch (err) {
this.logger.error({err}, 'Error compiling hints for cobalt');
}
}
delete opts.COBALT_SERVER_URI;
break;
case 'ibm': case 'ibm':
this.bugname = 'ibm_transcribe'; this.bugname = 'ibm_transcribe';
ep.addCustomEventListener(IbmTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep)); ep.addCustomEventListener(IbmTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(IbmTranscriptionEvents.Connect, this._onIbmConnect.bind(this, cs, ep)); ep.addCustomEventListener(IbmTranscriptionEvents.Connect, this._onIbmConnect.bind(this, cs, ep));
ep.addCustomEventListener(IbmTranscriptionEvents.ConnectFailure, ep.addCustomEventListener(IbmTranscriptionEvents.ConnectFailure,
this._onIbmConnectFailure.bind(this, cs, ep)); this._onIbmConnectFailure.bind(this, cs, ep));
ep.addCustomEventListener(IbmTranscriptionEvents.Error,
this._onIbmError.bind(this, cs, ep));
break; break;
case 'nvidia': case 'nvidia':
@@ -408,8 +451,6 @@ class TaskGather extends Task {
this._onTranscriptionComplete.bind(this, cs, ep)); this._onTranscriptionComplete.bind(this, cs, ep));
ep.addCustomEventListener(NvidiaTranscriptionEvents.VadDetected, ep.addCustomEventListener(NvidiaTranscriptionEvents.VadDetected,
this._onVadDetected.bind(this, cs, ep)); this._onVadDetected.bind(this, cs, ep));
ep.addCustomEventListener(NvidiaTranscriptionEvents.Error,
this._onNvidiaError.bind(this, cs, ep));
/* I think nvidia has this (??) - stall timers until prompt finishes playing */ /* I think nvidia has this (??) - stall timers until prompt finishes playing */
if ((this.sayTask || this.playTask) && this.listenDuringPrompt) { if ((this.sayTask || this.playTask) && this.listenDuringPrompt) {
@@ -418,11 +459,23 @@ class TaskGather extends Task {
break; break;
default: default:
this.notifyError({ msg: 'ASR error', details:`Invalid vendor ${this.vendor}`}); if (this.vendor.startsWith('custom:')) {
this.notifyTaskDone(); this.bugname = `${this.vendor}_transcribe`;
throw new Error(`Invalid vendor ${this.vendor}`); 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'));
} }
@@ -439,6 +492,7 @@ class TaskGather extends Task {
locale: this.language, locale: this.language,
interim: this.interim, interim: this.interim,
bugname: this.bugname, bugname: this.bugname,
hostport: this.hostport,
}).catch((err) => { }).catch((err) => {
const {writeAlerts, AlertType} = this.cs.srf.locals; const {writeAlerts, AlertType} = this.cs.srf.locals;
this.logger.error(err, 'TaskGather:_startTranscribing error'); this.logger.error(err, 'TaskGather:_startTranscribing error');
@@ -482,6 +536,22 @@ class TaskGather extends Task {
this._asrTimer = null; this._asrTimer = null;
} }
_startFastRecognitionTimer(evt) {
assert(this.fastRecognitionTimeout > 0);
this._clearFastRecognitionTimer();
this._fastRecognitionTimer = setTimeout(() => {
evt.is_final = true;
this._resolve('speech', evt);
}, this.fastRecognitionTimeout);
}
_clearFastRecognitionTimer() {
if (this._fastRecognitionTimer) {
clearTimeout(this._fastRecognitionTimer);
}
this._fastRecognitionTimer = null;
}
_startFinalAsrTimer() { _startFinalAsrTimer() {
this._clearFinalAsrTimer(); this._clearFinalAsrTimer();
this._finalAsrTimer = setTimeout(() => { this._finalAsrTimer = setTimeout(() => {
@@ -522,7 +592,7 @@ class TaskGather extends Task {
// 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'); const finished = fsEvent.getHeader('transcription-session-finished');
this.logger.debug({evt, bugname, finished}, 'Gather:_onTranscription'); 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 (this.vendor === 'ibm') { if (this.vendor === 'ibm') {
@@ -530,12 +600,26 @@ class TaskGather extends Task {
} }
evt = this.normalizeTranscription(evt, this.vendor, 1, this.language); evt = this.normalizeTranscription(evt, this.vendor, 1, this.language);
if (evt.alternatives.length === 0) {
this.logger.info({evt}, 'TaskGather:_onTranscription - got empty transcript, continue listening');
return;
}
/* fast path: our first partial transcript exactly matches an early hint */
if (this.earlyHintsMatch && evt.is_final === false && this.partialTranscriptsCount++ === 0) {
const transcript = evt.alternatives[0].transcript?.toLowerCase();
const hints = this.data.recognizer?.hints || [];
if (hints.find((h) => h.toLowerCase() === transcript)) {
this.logger.debug({evt}, 'Gather:_onTranscription: early hint match');
this._resolve('speech', evt);
return;
}
}
/* count words for bargein feature */ /* count words for bargein feature */
const words = evt.alternatives[0]?.transcript.split(' ').length; const words = evt.alternatives[0]?.transcript.split(' ').length;
const bufferedWords = this._bufferedTranscripts.reduce((count, e) => { const bufferedWords = this._sonioxTranscripts.length +
return count + e.alternatives[0]?.transcript.split(' ').length; this._bufferedTranscripts.reduce((count, e) => count + e.alternatives[0]?.transcript.split(' ').length, 0);
}, 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) {
@@ -544,7 +628,6 @@ class TaskGather extends Task {
} }
else { else {
this.logger.info({evt}, 'TaskGather:_onTranscription - got empty transcript, continue listening'); this.logger.info({evt}, 'TaskGather:_onTranscription - got empty transcript, continue listening');
//this._startTranscribing(ep);
} }
return; return;
} }
@@ -568,7 +651,9 @@ 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 { else {
if (this.bargein && (words + bufferedWords) < this.minBargeinWordCount) { if (this.bargein && (words + bufferedWords) < this.minBargeinWordCount) {
@@ -579,6 +664,12 @@ class TaskGather extends Task {
return; return;
} }
else { 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); this._resolve('speech', evt);
} }
} }
@@ -589,6 +680,8 @@ 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();
this._startTimer();
if (this.bargein && (words + bufferedWords) >= 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');
@@ -596,12 +689,22 @@ class TaskGather extends Task {
} }
this._killAudio(cs); this._killAudio(cs);
} }
if (this.fastRecognitionTimeout) {
this._startFastRecognitionTimer(evt);
}
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('verb:hook', 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) {
@@ -610,34 +713,67 @@ class TaskGather extends Task {
this._killAudio(cs); this._killAudio(cs);
} }
if (!this.resolved && !this.killed && !this._bufferedTranscripts.length) { /**
* By default, Gather asks google for multiple utterances.
* The reason is that we can sometimes get an 'end_of_utterance' event without
* getting a transcription. This can happen if someone coughs or mumbles.
* For that reason don't ask for a single utterance and we'll terminate the transcribe operation
* once we get a final transcript.
* However, if the usr has specified a singleUtterance, then we need to restart here
* since we dont have a final transcript yet.
*/
if (!this.resolved && !this.killed && !this._bufferedTranscripts.length && this.wantsSingleUtterance) {
this._startTranscribing(ep); this._startTranscribing(ep);
} }
} }
_onStartOfSpeech(cs, ep) { _onStartOfSpeech(cs, ep) {
this.logger.debug('TaskGather:_onStartOfSpeech'); this.logger.debug('TaskGather:_onStartOfSpeech');
if (this.bargein) {
this._killAudio(cs);
}
} }
_onTranscriptionComplete(cs, ep) { _onTranscriptionComplete(cs, ep) {
this.logger.debug('TaskGather:_onTranscriptionComplete'); this.logger.debug('TaskGather:_onTranscriptionComplete');
} }
_onNuanceError(cs, ep, evt) {
const {code, error, details} = evt;
if (code === 404 && error === 'No speech') {
this.logger.debug({code, error, details}, 'TaskGather:_onNuanceError');
return this._resolve('timeout');
}
this.logger.info({code, error, details}, 'TaskGather:_onNuanceError');
if (code === 413 && error === 'Too much speech') {
return this._resolve('timeout');
}
}
_onNvidiaError(cs, ep, evt) {
this.logger.info({evt}, 'TaskGather:_onNvidiaError');
}
_onDeepgramConnect(_cs, _ep) { _onDeepgramConnect(_cs, _ep) {
this.logger.debug('TaskGather:_onDeepgramConnect'); this.logger.debug('TaskGather:_onDeepgramConnect');
} }
_onJambonzConnect(_cs, _ep) {
this.logger.debug('TaskGather:_onJambonzConnect');
}
async _onJambonzError(cs, ep, evt) {
this.logger.info({evt}, 'TaskGather:_onJambonzError');
if (this.isHandledByPrimaryProvider && this.fallbackVendor) {
ep.stopTranscription({vendor: this.vendor})
.catch((err) => this.logger.error({err}, `Error stopping transcription for primary vendor ${this.vendor}`));
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
try {
await this._fallback();
await this._initSpeech(cs, ep);
this._startTranscribing(ep);
updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid);
return;
} catch (error) {
this.logger.info({error}, `There is error while falling back to ${this.fallbackVendor}`);
}
}
const {writeAlerts, AlertType} = cs.srf.locals;
if (this.vendor === 'nuance') {
const {code, error} = evt;
if (code === 404 && error === 'No speech') return this._resolve('timeout');
if (code === 413 && error === 'Too much speech') return this._resolve('timeout');
}
this.logger.info({evt}, 'TaskGather:_onJambonzError');
writeAlerts({
account_sid: cs.accountSid,
alert_type: AlertType.STT_FAILURE,
message: `Custom speech vendor ${this.vendor} error: ${evt.error}`,
vendor: this.vendor,
}).catch((err) => this.logger.info({err}, 'Error generating alert for jambonz custom connection failure'));
this.notifyError({msg: 'ASR error', details:`Custom speech vendor ${this.vendor} error: ${evt.error}`});
}
_onDeepGramConnectFailure(cs, _ep, evt) { _onDeepGramConnectFailure(cs, _ep, evt) {
const {reason} = evt; const {reason} = evt;
@@ -652,6 +788,19 @@ class TaskGather extends Task {
this.notifyError({msg: 'ASR error', details:`Failed connecting to speech vendor deepgram: ${reason}`}); this.notifyError({msg: 'ASR error', details:`Failed connecting to speech vendor deepgram: ${reason}`});
this.notifyTaskDone(); 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) { _onIbmConnect(_cs, _ep) {
this.logger.debug('TaskGather:_onIbmConnect'); this.logger.debug('TaskGather:_onIbmConnect');
@@ -701,8 +850,13 @@ class TaskGather extends Task {
if (this.resolved) return; if (this.resolved) return;
this.resolved = true; this.resolved = true;
// Clear dtmf event
if (this.dtmfBargein) {
this.ep.removeAllListeners('dtmf');
}
clearTimeout(this.interDigitTimer); clearTimeout(this.interDigitTimer);
this._clearTimer(); this._clearTimer();
this._clearFastRecognitionTimer();
if (this.isContinuousAsr && reason.startsWith('speech')) { if (this.isContinuousAsr && reason.startsWith('speech')) {
evt = { evt = {
@@ -716,7 +870,11 @@ class TaskGather extends Task {
this.logger.debug({evt}, 'TaskGather:resolve buffered results'); this.logger.debug({evt}, 'TaskGather:resolve buffered results');
} }
this.span.setAttributes({'stt.resolve': reason, 'stt.result': JSON.stringify(evt)}); this.span.setAttributes({
channel: 1,
'stt.resolve': reason,
'stt.result': JSON.stringify(evt)
});
if (this.needsStt && 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

@@ -25,6 +25,13 @@ class Lex extends Task {
this.vendor = this.data.tts.vendor || 'default'; this.vendor = this.data.tts.vendor || 'default';
this.language = this.data.tts.language || 'default'; this.language = this.data.tts.language || 'default';
this.voice = this.data.tts.voice || 'default'; this.voice = this.data.tts.voice || 'default';
this.speechCredentialLabel = this.data.tts.label || 'default';
// fallback tts
this.fallbackVendor = this.data.tts.fallbackVendor || 'default';
this.fallbackLanguage = this.data.tts.fallbackLanguage || 'default';
this.fallbackVoice = this.data.tts.fallbackLanguage || 'default';
this.fallbackLabel = this.data.tts.fallbackLabel || 'default';
} }
this.botName = `${this.bot}:${this.alias}:${this.region}`; this.botName = `${this.bot}:${this.alias}:${this.region}`;
@@ -102,8 +109,16 @@ class Lex extends Task {
this.vendor = cs.speechSynthesisVendor; this.vendor = cs.speechSynthesisVendor;
this.language = cs.speechSynthesisLanguage; this.language = cs.speechSynthesisLanguage;
this.voice = cs.speechSynthesisVoice; this.voice = cs.speechSynthesisVoice;
this.speechCredentialLabel = cs.speechSynthesisLabel;
} }
this.ttsCredentials = cs.getSpeechCredentials(this.vendor, 'tts'); if (this.fallbackVendor === 'default') {
this.fallbackVendor = cs.fallbackSpeechSynthesisVendor;
this.fallbackLanguage = cs.fallbackSpeechSynthesisLanguage;
this.fallbackVoice = cs.fallbackSpeechSynthesisVoice;
this.fallbackLabel = cs.fallbackSpeechSynthesisLabel;
}
this.ttsCredentials = cs.getSpeechCredentials(this.vendor, 'tts', this.speechCredentialLabel);
this.ep.addCustomEventListener('lex::intent', this._onIntent.bind(this, ep, cs)); this.ep.addCustomEventListener('lex::intent', this._onIntent.bind(this, ep, cs));
this.ep.addCustomEventListener('lex::transcription', this._onTranscription.bind(this, ep, cs)); this.ep.addCustomEventListener('lex::transcription', this._onTranscription.bind(this, ep, cs));
@@ -168,6 +183,41 @@ class Lex extends Task {
} }
} }
async _fallbackSynthAudio(cs, msg, stats, synthAudio) {
try {
const {filePath} = await synthAudio(stats, {
account_sid: cs.accountSid,
text: msg,
vendor: this.vendor,
language: this.language,
voice: this.voice,
salt: cs.callSid,
credentials: this.ttsCredentials
});
return filePath;
} catch (error) {
this.logger.info({error}, 'failed to synth audio from primary vendor');
if (this.fallbackVendor) {
try {
const credential = cs.getSpeechCredentials(this.fallbackVendor, 'tts', this.fallbackLabel);
const {filePath} = await synthAudio(stats, {
account_sid: cs.accountSid,
text: msg,
vendor: this.fallbackVendor,
language: this.fallbackLanguage,
voice: this.fallbackVoice,
salt: cs.callSid,
credentials: credential
});
return filePath;
} catch (err) {
this.logger.info({err}, 'failed to synth audio from fallback vendor');
}
}
}
}
/** /**
* @param {*} evt - event data * @param {*} evt - event data
*/ */
@@ -187,15 +237,7 @@ class Lex extends Task {
try { try {
this.logger.debug(`tts with ${this.vendor} ${this.voice}`); this.logger.debug(`tts with ${this.vendor} ${this.voice}`);
// eslint-disable-next-line no-unused-vars const filePath = await this._fallbackSynthAudio(cs, msg, stats, synthAudio);
const {filePath, servedFromCache} = await synthAudio(stats, {
text: msg,
vendor: this.vendor,
language: this.language,
voice: this.voice,
salt: cs.callSid,
credentials: this.ttsCredentials
});
if (filePath) cs.trackTmpFile(filePath); if (filePath) cs.trackTmpFile(filePath);
if (this.events.includes('start-play')) { if (this.events.includes('start-play')) {

View File

@@ -2,10 +2,13 @@ const Task = require('./task');
const {TaskName, TaskPreconditions, ListenEvents, ListenStatus} = require('../utils/constants'); const {TaskName, TaskPreconditions, ListenEvents, ListenStatus} = require('../utils/constants');
const makeTask = require('./make_task'); const makeTask = require('./make_task');
const moment = require('moment'); const moment = require('moment');
const MAX_PLAY_AUDIO_QUEUE_SIZE = 10;
const DTMF_SPAN_NAME = 'dtmf';
class TaskListen extends Task { class TaskListen extends Task {
constructor(logger, opts, parentTask) { constructor(logger, opts, parentTask) {
super(logger, opts); super(logger, opts);
this.disableBidirectionalAudio = opts.disableBidirectionalAudio;
this.preconditions = TaskPreconditions.Endpoint; this.preconditions = TaskPreconditions.Endpoint;
[ [
@@ -20,12 +23,18 @@ class TaskListen extends Task {
this.nested = parentTask instanceof Task; this.nested = parentTask instanceof Task;
this.results = {}; this.results = {};
this.playAudioQueue = [];
this.isPlayingAudioFromQueue = false;
if (this.transcribe) this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe}, this); if (this.transcribe) this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe}, this);
} }
get name() { return TaskName.Listen; } get name() { return TaskName.Listen; }
set bugname(name) { this._bugname = name; }
set ignoreCustomerData(val) { this._ignoreCustomerData = val; }
async exec(cs, {ep}) { async exec(cs, {ep}) {
await super.exec(cs); await super.exec(cs);
this.ep = ep; this.ep = ep;
@@ -58,10 +67,12 @@ class TaskListen extends Task {
super.kill(cs); super.kill(cs);
this.logger.debug(`TaskListen:kill endpoint connected? ${this.ep && this.ep.connected}`); this.logger.debug(`TaskListen:kill endpoint connected? ${this.ep && this.ep.connected}`);
this._clearTimer(); this._clearTimer();
this.playAudioQueue = [];
if (this.ep && this.ep.connected) { if (this.ep && this.ep.connected) {
this.logger.debug('TaskListen:kill closing websocket'); this.logger.debug('TaskListen:kill closing websocket');
try { try {
await this.ep.forkAudioStop(); const args = this._bugname ? [this._bugname] : [];
await this.ep.forkAudioStop(...args);
this.logger.debug('TaskListen:kill successfully closed websocket'); this.logger.debug('TaskListen:kill successfully closed websocket');
} catch (err) { } catch (err) {
this.logger.info(err, 'TaskListen:kill'); this.logger.info(err, 'TaskListen:kill');
@@ -81,13 +92,16 @@ class TaskListen extends Task {
async updateListen(status) { async updateListen(status) {
if (!this.killed && this.ep && this.ep.connected) { if (!this.killed && this.ep && this.ep.connected) {
const args = this._bugname ? [this._bugname] : [];
this.logger.info(`TaskListen:updateListen status ${status}`); this.logger.info(`TaskListen:updateListen status ${status}`);
switch (status) { switch (status) {
case ListenStatus.Pause: case ListenStatus.Pause:
await this.ep.forkAudioPause().catch((err) => this.logger.info(err, 'TaskListen: error pausing audio')); await this.ep.forkAudioPause(...args)
.catch((err) => this.logger.info(err, 'TaskListen: error pausing audio'));
break; break;
case ListenStatus.Resume: case ListenStatus.Resume:
await this.ep.forkAudioResume().catch((err) => this.logger.info(err, 'TaskListen: error resuming audio')); await this.ep.forkAudioResume(...args)
.catch((err) => this.logger.info(err, 'TaskListen: error resuming audio'));
break; break;
} }
} }
@@ -100,9 +114,13 @@ class TaskListen extends Task {
async _startListening(cs, ep) { async _startListening(cs, ep) {
this._initListeners(ep); this._initListeners(ep);
const ci = this.nested ? this.parentTask.sd.callInfo : cs.callInfo.toJSON();
if (this._ignoreCustomerData) {
delete ci.customerData;
}
const metadata = Object.assign( const metadata = Object.assign(
{sampleRate: this.sampleRate, mixType: this.mixType}, {sampleRate: this.sampleRate, mixType: this.mixType},
this.nested ? this.parentTask.sd.callInfo : cs.callInfo.toJSON(), ci,
this.metadata); this.metadata);
if (this.hook.auth) { if (this.hook.auth) {
this.logger.debug({username: this.hook.auth.username, password: this.hook.auth.password}, this.logger.debug({username: this.hook.auth.username, password: this.hook.auth.password},
@@ -116,6 +134,7 @@ class TaskListen extends Task {
wsUrl: this.hook.url, wsUrl: this.hook.url,
mixType: this.mixType, mixType: this.mixType,
sampling: this.sampleRate, sampling: this.sampleRate,
...(this._bugname && {bugname: this._bugname}),
metadata metadata
}); });
this.recordStartTime = moment(); this.recordStartTime = moment();
@@ -136,7 +155,7 @@ class TaskListen extends Task {
} }
/* support bi-directional audio */ /* support bi-directional audio */
if (!this.disableBiDirectionalAudio) { if (!this.disableBidirectionalAudio) {
ep.addCustomEventListener(ListenEvents.PlayAudio, this._onPlayAudio.bind(this, ep)); 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));
@@ -157,12 +176,25 @@ class TaskListen extends Task {
} }
_onDtmf(ep, evt) { _onDtmf(ep, evt) {
this.logger.debug({evt}, `TaskListen:_onDtmf received dtmf ${evt.dtmf}`); const {dtmf, duration} = evt;
this.logger.debug({evt}, `TaskListen:_onDtmf received dtmf ${dtmf}`);
if (this.passDtmf && this.ep?.connected) { if (this.passDtmf && this.ep?.connected) {
const obj = {event: 'dtmf', dtmf: evt.dtmf, duration: evt.duration}; const obj = {event: 'dtmf', dtmf, duration};
this.ep.forkAudioSendText(obj) const args = this._bugname ? [this._bugname, obj] : [obj];
this.ep.forkAudioSendText(...args)
.catch((err) => this.logger.info({err}, 'TaskListen:_onDtmf error sending dtmf')); .catch((err) => this.logger.info({err}, 'TaskListen:_onDtmf error sending dtmf'));
} }
/* add a child span for the dtmf event */
const msDuration = Math.floor((duration / 8000) * 1000);
const {span} = this.startChildSpan(`${DTMF_SPAN_NAME}:${dtmf}`);
span.setAttributes({
channel: 1,
dtmf,
duration: `${msDuration}ms`
});
span.end();
if (evt.dtmf === this.finishOnKey) { if (evt.dtmf === this.finishOnKey) {
this.logger.info(`TaskListen:_onDtmf terminating task due to dtmf ${evt.dtmf}`); this.logger.info(`TaskListen:_onDtmf terminating task due to dtmf ${evt.dtmf}`);
this.results.digits = evt.dtmf; this.results.digits = evt.dtmf;
@@ -184,16 +216,44 @@ class TaskListen extends Task {
this.notifyTaskDone(); this.notifyTaskDone();
} }
async _onPlayAudio(ep, evt) { async _playAudio(ep, evt, logger) {
this.logger.info(`received play_audio event: ${JSON.stringify(evt)}`);
try { try {
const results = await ep.play(evt.file); const results = await ep.play(evt.file);
this.logger.debug(`Finished playing file, result: ${JSON.stringify(results)}`); logger.debug(`Finished playing file, result: ${JSON.stringify(results)}`);
ep.forkAudioSendText({type: 'playDone', data: Object.assign({id: evt.id}, results)}); const obj = {
type: 'playDone',
data: {
id: evt.id,
...results
}
};
const args = this._bugname ? [this._bugname, obj] : [obj];
ep.forkAudioSendText(...args);
} catch (err) {
logger.error({err}, 'Error playing file');
} }
catch (err) { }
this.logger.error({err}, 'Error playing file');
async _onPlayAudio(ep, evt) {
this.logger.info(`received play_audio event: ${JSON.stringify(evt)}`);
if (!evt.queuePlay) {
this.playAudioQueue = [];
this._playAudio(ep, evt, this.logger);
this.isPlayingAudioFromQueue = false;
return;
} }
if (this.playAudioQueue.length <= MAX_PLAY_AUDIO_QUEUE_SIZE) {
this.playAudioQueue.push(evt);
}
if (this.isPlayingAudioFromQueue) return;
this.isPlayingAudioFromQueue = true;
while (this.playAudioQueue.length > 0) {
await this._playAudio(ep, this.playAudioQueue.shift(), this.logger);
}
this.isPlayingAudioFromQueue = false;
} }
_onKillAudio(ep) { _onKillAudio(ep) {

View File

@@ -2,7 +2,7 @@ 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 uuidv4 = require('uuid-random'); const uuidv4 = require('uuid-random');
const {K8S} = require('../config');
class TaskMessage extends Task { class TaskMessage extends Task {
constructor(logger, opts) { constructor(logger, opts) {
super(logger, opts); super(logger, opts);
@@ -42,7 +42,7 @@ class TaskMessage extends Task {
} }
if (gw) { if (gw) {
this.logger.info({gw, accountSid}, 'Message:exec - using smpp to send message'); this.logger.info({gw, accountSid}, 'Message:exec - using smpp to send message');
url = process.env.K8S ? 'http://smpp' : getSmpp(); url = K8S ? 'http://smpp' : getSmpp();
relativeUrl = '/sms'; relativeUrl = '/sms';
payload = { payload = {
...payload, ...payload,

View File

@@ -37,6 +37,7 @@ class TaskPlay extends Task {
}, this.timeoutSecs * 1000); }, 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;
@@ -80,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

@@ -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.callerName = this.data.callerName;
this.fromHost = this.data.fromHost; 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;
@@ -22,37 +23,53 @@ class TaskRestDial extends Task {
get name() { return TaskName.RestDial; } get name() { return TaskName.RestDial; }
set appJson(app_json) {
this.app_json = app_json;
}
/** /**
* INVITE has just been sent at this point * INVITE has just been sent at this point
*/ */
async exec(cs) { async exec(cs) {
await super.exec(cs); await super.exec(cs);
this.req = cs.req; this.cs = cs;
this.canCancel = true;
if (this.data.amd) {
this.startAmd = cs.startAmd;
this.stopAmd = cs.stopAmd;
this.on('amd', this._onAmdEvent.bind(this, cs));
}
this._setCallTimer(); this._setCallTimer();
await this.awaitTaskDone(); await this.awaitTaskDone();
} }
turnOffAmd() {
if (this.callSession.ep && this.callSession.ep.amd) this.stopAmd(this.callSession.ep, this);
}
kill(cs) { kill(cs) {
super.kill(cs); super.kill(cs);
this._clearCallTimer(); this._clearCallTimer();
if (this.req) { if (this.canCancel) {
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);
this.logger.debug('TaskRestDial:_onConnect - call connected');
try { try {
const b3 = this.getTracingPropagation(); const b3 = this.getTracingPropagation();
const httpHeaders = b3 && {b3}; const httpHeaders = b3 && {b3};
const params = { const params = {
...cs.callInfo, ...(cs.callInfo.toJSON()),
defaults: { defaults: {
synthesizer: { synthesizer: {
vendor: cs.speechSynthesisVendor, vendor: cs.speechSynthesisVendor,
@@ -65,7 +82,21 @@ class TaskRestDial extends Task {
} }
} }
}; };
const tasks = await cs.requestor.request('session:new', this.call_hook, params, httpHeaders); if (this.startAmd) {
try {
this.startAmd(this.callSession, this.callSession.ep, this, this.data.amd);
} catch (err) {
this.logger.info({err}, 'Rest:dial:Call established - Error calling startAmd');
}
}
let tasks;
if (this.app_json) {
this.logger.debug('TaskRestDial: using app_json from task data');
tasks = JSON.parse(this.app_json);
} else {
this.logger.debug({call_hook: this.call_hook}, 'TaskRestDial: retrieving application');
tasks = await cs.requestor.request('session:new', this.call_hook, params, httpHeaders);
}
if (tasks && Array.isArray(tasks)) { if (tasks && Array.isArray(tasks)) {
this.logger.debug({tasks: tasks}, `TaskRestDial: replacing application with ${tasks.length} tasks`); this.logger.debug({tasks: tasks}, `TaskRestDial: replacing application with ${tasks.length} tasks`);
cs.replaceApplication(normalizeJambones(this.logger, tasks).map((tdata) => makeTask(this.logger, tdata))); cs.replaceApplication(normalizeJambones(this.logger, tasks).map((tdata) => makeTask(this.logger, tdata)));
@@ -79,7 +110,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();
} }
@@ -97,7 +128,19 @@ class TaskRestDial extends Task {
_onCallTimeout() { _onCallTimeout() {
this.logger.debug('TaskRestDial: timeout expired without answer, killing task'); this.logger.debug('TaskRestDial: timeout expired without answer, killing task');
this.timer = null; this.timer = null;
this.kill(); if (this.canCancel) {
this.canCancel = false;
this.cs?.req?.cancel();
}
}
_onAmdEvent(cs, evt) {
this.logger.info({evt}, 'Rest:dial:_onAmdEvent');
const {actionHook} = this.data.amd;
this.performHook(cs, actionHook, evt)
.catch((err) => {
this.logger.error({err}, 'Rest:dial:_onAmdEvent - error calling actionHook');
});
} }
} }

View File

@@ -36,6 +36,8 @@ class TaskSay extends Task {
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia); this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
this.synthesizer = this.data.synthesizer || {}; this.synthesizer = this.data.synthesizer || {};
this.disableTtsCache = this.data.disableTtsCache; this.disableTtsCache = this.data.disableTtsCache;
this.options = this.synthesizer.options || {};
this.isHandledByPrimaryProvider = true;
} }
get name() { return TaskName.Say; } get name() { return TaskName.Say; }
@@ -48,26 +50,15 @@ class TaskSay extends Task {
return `${this.name}{${this.text[0]}}`; return `${this.name}{${this.text[0]}}`;
} }
async exec(cs, {ep}) { async _synthesizeWithSpecificVendor(cs, ep, {vendor, language, voice, label}) {
await super.exec(cs);
const {srf} = cs; const {srf} = cs;
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, srf); const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, srf);
const {writeAlerts, AlertType, stats} = srf.locals; const {writeAlerts, AlertType, stats} = srf.locals;
const {synthAudio} = srf.locals.dbHelpers; const {synthAudio} = srf.locals.dbHelpers;
const vendor = this.synthesizer.vendor && this.synthesizer.vendor !== 'default' ?
this.synthesizer.vendor :
cs.speechSynthesisVendor;
const language = this.synthesizer.language && this.synthesizer.language !== 'default' ?
this.synthesizer.language :
cs.speechSynthesisLanguage ;
let voice = this.synthesizer.voice && this.synthesizer.voice !== 'default' ?
this.synthesizer.voice :
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');
let credentials = cs.getSpeechCredentials(vendor, 'tts', label);
/* parse Nuance voices into name and model */ /* parse Nuance voices into name and model */
let model; let model;
if (vendor === 'nuance' && voice) { if (vendor === 'nuance' && voice) {
@@ -78,8 +69,17 @@ class TaskSay extends Task {
} }
} }
/* allow for microsoft custom region voice and api_key to be specified as an override */
if (vendor === 'microsoft' && this.options.deploymentId) {
credentials = credentials || {};
credentials.use_custom_tts = true;
credentials.custom_tts_endpoint = this.options.deploymentId;
credentials.api_key = this.options.apiKey || credentials.apiKey;
credentials.region = this.options.region || credentials.region;
voice = this.options.voice || voice;
}
this.logger.info({vendor, language, voice, model}, 'TaskSay:exec'); this.logger.info({vendor, language, voice, model}, 'TaskSay:exec');
this.ep = ep;
try { try {
if (!credentials) { if (!credentials) {
writeAlerts({ writeAlerts({
@@ -109,6 +109,7 @@ class TaskSay extends Task {
}); });
try { try {
const {filePath, servedFromCache, rtt} = await synthAudio(stats, { const {filePath, servedFromCache, rtt} = await synthAudio(stats, {
account_sid: cs.accountSid,
text, text,
vendor, vendor,
language, language,
@@ -143,37 +144,88 @@ class TaskSay extends Task {
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({msg: 'TTS error', details: err.message || err}); this.notifyError({msg: 'TTS error', details: err.message || err});
return; throw err;
} }
}; };
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); return (await Promise.all(arr)).filter((fp) => fp && fp.length);
this.logger.debug({filepath}, 'synthesized files for tts');
this.notifyStatus({event: 'start-playback'});
while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep?.connected) {
let segment = 0;
while (!this.killed && segment < filepath.length) {
if (cs.isInConference) {
const {memberId, confName, confUuid} = cs;
await this.playToConfMember(this.ep, memberId, confName, confUuid, filepath[segment]);
}
else {
this.logger.debug(`Say:exec sending command to play file ${filepath[segment]}`);
await ep.play(filepath[segment]);
this.logger.debug(`Say:exec completed play file ${filepath[segment]}`);
}
segment++;
}
}
} catch (err) { } catch (err) {
this.logger.info(err, 'TaskSay:exec error'); this.logger.info(err, 'TaskSay:exec error');
throw err;
}
}
async exec(cs, {ep}) {
await super.exec(cs);
this.ep = ep;
const vendor = this.synthesizer.vendor && this.synthesizer.vendor !== 'default' ?
this.synthesizer.vendor :
cs.speechSynthesisVendor;
const language = this.synthesizer.language && this.synthesizer.language !== 'default' ?
this.synthesizer.language :
cs.speechSynthesisLanguage ;
const voice = this.synthesizer.voice && this.synthesizer.voice !== 'default' ?
this.synthesizer.voice :
cs.speechSynthesisVoice;
const label = this.synthesizer.label && this.synthesizer.label !== 'default' ?
this.synthesizer.label :
cs.speechSynthesisLabel;
const fallbackVendor = this.synthesizer.fallbackVendor && this.synthesizer.fallbackVendor !== 'default' ?
this.synthesizer.fallbackVendor :
cs.fallbackSpeechSynthesisVendor;
const fallbackLanguage = this.synthesizer.fallbackLanguage && this.synthesizer.fallbackLanguage !== 'default' ?
this.synthesizer.fallbackLanguage :
cs.fallbackSpeechSynthesisLanguage ;
const fallbackVoice = this.synthesizer.fallbackVoice && this.synthesizer.fallbackVoice !== 'default' ?
this.synthesizer.fallbackVoice :
cs.fallbackSpeechSynthesisVoice;
const fallbackLabel = this.synthesizer.fallbackLabel && this.synthesizer.fallbackLabel !== 'default' ?
this.synthesizer.fallbackLabel :
cs.fallbackSpeechSynthesisLabel;
let filepath;
try {
filepath = await this._synthesizeWithSpecificVendor(cs, ep, {vendor, language, voice, label});
} catch (error) {
if (fallbackVendor && this.isHandledByPrimaryProvider) {
this.isHandledByPrimaryProvider = false;
this.logger.info(`Synthesize error, fallback to ${fallbackVendor}`);
filepath = await this._synthesizeWithSpecificVendor(cs, ep,
{
vendor: fallbackVendor,
language: fallbackLanguage,
voice: fallbackVoice,
label: fallbackLabel
});
} else {
throw error;
}
}
this.notifyStatus({event: 'start-playback'});
while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep?.connected) {
let segment = 0;
while (!this.killed && segment < filepath.length) {
if (cs.isInConference) {
const {memberId, confName, confUuid} = cs;
await this.playToConfMember(this.ep, memberId, confName, confUuid, filepath[segment]);
}
else {
this.logger.debug(`Say:exec sending command to play file ${filepath[segment]}`);
await ep.play(filepath[segment]);
this.logger.debug(`Say:exec completed play file ${filepath[segment]}`);
}
segment++;
}
} }
this.emit('playDone'); this.emit('playDone');
} }

139
lib/tasks/stt-task.js Normal file
View File

@@ -0,0 +1,139 @@
const Task = require('./task');
const assert = require('assert');
const crypto = require('crypto');
const { TaskPreconditions, CobaltTranscriptionEvents } = require('../utils/constants');
class SttTask extends Task {
constructor(logger, data, parentTask) {
super(logger, data);
this.parentTask = parentTask;
this.preconditions = TaskPreconditions.Endpoint;
const {
setChannelVarsForStt,
normalizeTranscription,
removeSpeechListeners,
setSpeechCredentialsAtRuntime,
compileSonioxTranscripts
} = require('../utils/transcription-utils')(logger);
this.setChannelVarsForStt = setChannelVarsForStt;
this.normalizeTranscription = normalizeTranscription;
this.removeSpeechListeners = removeSpeechListeners;
this.compileSonioxTranscripts = compileSonioxTranscripts;
this.isHandledByPrimaryProvider = true;
if (this.data.recognizer) {
const recognizer = this.data.recognizer;
this.vendor = recognizer.vendor;
this.language = recognizer.language;
this.label = recognizer.label;
//fallback
this.fallbackVendor = recognizer.fallbackVendor || 'default';
this.fallbackLanguage = recognizer.fallbackLanguage || 'default';
this.fallbackLabel = recognizer.fallbackLabel || 'default';
/* let credentials be supplied in the recognizer object at runtime */
this.sttCredentials = setSpeechCredentialsAtRuntime(recognizer);
} else {
this.data.recognizer = {hints: [], altLanguages: []};
}
/* buffer for soniox transcripts */
this._sonioxTranscripts = [];
}
async _initSpeechCredentials(cs, vendor, label) {
const {getNuanceAccessToken, getIbmAccessToken} = this.cs.srf.locals.dbHelpers;
let credentials = cs.getSpeechCredentials(vendor, 'stt', label);
if (!credentials) {
const {writeAlerts, AlertType} = cs.srf.locals;
this.logger.info(`ERROR stt using ${vendor} requested but creds not supplied`);
writeAlerts({
account_sid: cs.accountSid,
alert_type: AlertType.STT_NOT_PROVISIONED,
vendor
}).catch((err) => this.logger.info({err}, 'Error generating alert for no stt'));
// Notify application that STT vender is wrong.
this.notifyError({
msg: 'ASR error',
details: `No speech-to-text service credentials for ${vendor} have been configured`
});
this.notifyTaskDone();
throw new Error(`No speech-to-text service credentials for ${vendor} have been configured`);
}
if (vendor === 'nuance' && credentials.client_id) {
/* get nuance access token */
const {client_id, secret} = credentials;
const {access_token, servedFromCache} = await getNuanceAccessToken(client_id, secret, 'asr tts');
this.logger.debug({client_id}, `got nuance access token ${servedFromCache ? 'from cache' : ''}`);
credentials = {...credentials, access_token};
}
else if (vendor == 'ibm' && credentials.stt_api_key) {
/* get ibm access token */
const {stt_api_key, stt_region} = credentials;
const {access_token, servedFromCache} = await getIbmAccessToken(stt_api_key);
this.logger.debug({stt_api_key}, `got ibm access token ${servedFromCache ? 'from cache' : ''}`);
credentials = {...credentials, access_token, stt_region};
}
return credentials;
}
async _fallback() {
assert(this.fallbackVendor, 'fallback failed without fallbackVendor configuration');
this.isHandledByPrimaryProvider = false;
this.logger.info(`Failed to use primary STT provider, fallback to ${this.fallbackVendor}`);
this.vendor = this.fallbackVendor;
this.language = this.fallbackLanguage;
this.label = this.fallbackLabel;
this.data.recognizer.vendor = this.vendor;
this.data.recognizer.language = this.language;
this.data.recognizer.label = this.label;
this.sttCredentials = await this._initSpeechCredentials(this.cs, this.vendor, this.label);
}
async compileHintsForCobalt(ep, hostport, model, token, hints) {
const {retrieveKey} = this.cs.srf.locals.dbHelpers;
const hash = crypto.createHash('sha1');
hash.update(`${model}:${hints}`);
const key = `cobalt:${hash.digest('hex')}`;
this.context = await retrieveKey(key);
if (this.context) {
this.logger.debug({model, hints}, 'found cached cobalt context for supplied hints');
return this.context;
}
this.logger.debug({model, hints}, 'compiling cobalt context for supplied hints');
return new Promise((resolve, reject) => {
this.cobaltCompileResolver = resolve;
ep.addCustomEventListener(CobaltTranscriptionEvents.CompileContext, this._onCompileContext.bind(this, ep, key));
ep.api('uuid_cobalt_compile_context', [ep.uuid, hostport, model, token, hints], (err, evt) => {
if (err || 0 !== evt.getBody().indexOf('+OK')) {
ep.removeCustomEventListener(CobaltTranscriptionEvents.CompileContext);
return reject(err);
}
});
});
}
_onCompileContext(ep, key, evt) {
const {addKey} = this.cs.srf.locals.dbHelpers;
this.logger.debug({evt}, `received cobalt compile context event, will cache under ${key}`);
this.cobaltCompileResolver(evt.compiled_context);
ep.removeCustomEventListener(CobaltTranscriptionEvents.CompileContext);
this.cobaltCompileResolver = null;
//cache the compiled context
addKey(key, evt.compiled_context, 3600 * 12)
.catch((err) => this.logger.info({err}, `Error caching cobalt context for ${key}`));
}
}
module.exports = SttTask;

View File

@@ -155,7 +155,7 @@ class Task extends Emitter {
if (this.actionHook) { if (this.actionHook) {
const type = this.name === TaskName.Redirect ? 'session:redirect' : 'verb:hook'; 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(type, {'hook.url': this.actionHook}); const span = this.startSpan(`${type} (${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)});

View File

@@ -1,47 +1,42 @@
const Task = require('./task'); const assert = require('assert');
const { const {
TaskName, TaskName,
TaskPreconditions,
GoogleTranscriptionEvents, GoogleTranscriptionEvents,
AzureTranscriptionEvents,
AwsTranscriptionEvents,
NuanceTranscriptionEvents, NuanceTranscriptionEvents,
AwsTranscriptionEvents,
AzureTranscriptionEvents,
DeepgramTranscriptionEvents, DeepgramTranscriptionEvents,
SonioxTranscriptionEvents,
CobaltTranscriptionEvents,
IbmTranscriptionEvents, IbmTranscriptionEvents,
NvidiaTranscriptionEvents NvidiaTranscriptionEvents,
JambonzTranscriptionEvents,
TranscribeStatus
} = require('../utils/constants'); } = require('../utils/constants');
const { normalizeJambones } = require('@jambonz/verb-specifications'); const { normalizeJambones } = require('@jambonz/verb-specifications');
const SttTask = require('./stt-task');
class TaskTranscribe extends Task { const STT_LISTEN_SPAN_NAME = 'stt-listen';
class TaskTranscribe extends SttTask {
constructor(logger, opts, parentTask) { constructor(logger, opts, parentTask) {
super(logger, opts); super(logger, opts, parentTask);
this.preconditions = TaskPreconditions.Endpoint;
this.parentTask = parentTask;
const {
setChannelVarsForStt,
normalizeTranscription,
removeSpeechListeners,
setSpeechCredentialsAtRuntime
} = require('../utils/transcription-utils')(logger);
this.setChannelVarsForStt = setChannelVarsForStt;
this.normalizeTranscription = normalizeTranscription;
this.removeSpeechListeners = removeSpeechListeners;
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);
const recognizer = this.data.recognizer; if (this.data.recognizer) {
this.vendor = recognizer.vendor; this.interim = !!this.data.recognizer.interim;
this.language = recognizer.language; this.separateRecognitionPerChannel = this.data.recognizer.separateRecognitionPerChannel;
this.interim = !!recognizer.interim; }
this.separateRecognitionPerChannel = recognizer.separateRecognitionPerChannel;
/* let credentials be supplied in the recognizer object at runtime */ this.childSpan = [null, null];
this.sttCredentials = setSpeechCredentialsAtRuntime(recognizer);
recognizer.hints = recognizer.hints || []; // Continuos asr timeout
recognizer.altLanguages = recognizer.altLanguages || []; this.asrTimeout = typeof this.data.recognizer.asrTimeout === 'number' ? this.data.recognizer.asrTimeout * 1000 : 0;
this.isContinuousAsr = this.asrTimeout > 0;
/* buffer speech for continuous asr */
this._bufferedTranscripts = [];
} }
get name() { return TaskName.Transcribe; } get name() { return TaskName.Transcribe; }
@@ -49,7 +44,6 @@ class TaskTranscribe extends Task {
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) { if (cs.hasGlobalSttHints) {
const {hints, hintsBoost} = cs.globalSttHints; const {hints, hintsBoost} = cs.globalSttHints;
@@ -77,38 +71,50 @@ class TaskTranscribe extends Task {
this.language = cs.speechRecognizerLanguage; this.language = cs.speechRecognizerLanguage;
if (this.data.recognizer) this.data.recognizer.language = this.language; if (this.data.recognizer) this.data.recognizer.language = this.language;
} }
if ('default' === this.label || !this.label) {
this.label = cs.speechRecognizerLabel;
if (this.data.recognizer) this.data.recognizer.label = this.label;
}
// Fallback options
if ('default' === this.fallbackVendor || !this.fallbackVendor) {
this.fallbackVendor = cs.fallbackSpeechRecognizerVendor;
if (this.data.recognizer) this.data.recognizer.fallbackVendor = this.fallbackVendor;
}
if ('default' === this.fallbackLanguage || !this.fallbackLanguage) {
this.fallbackLanguage = cs.fallbackSpeechRecognizerLanguage;
if (this.data.recognizer) this.data.recognizer.fallbackLanguage = this.fallbackLanguage;
}
if ('default' === this.fallbackLabel || !this.fallbackLabel) {
this.fallbackLabel = cs.fallbackSpeechRecognizerLabel;
if (this.data.recognizer) this.data.recognizer.fallbackLabel = this.fallbackLabel;
}
if (!this.data.recognizer.vendor) { if (!this.data.recognizer.vendor) {
this.data.recognizer.vendor = this.vendor; this.data.recognizer.vendor = this.vendor;
} }
if (!this.sttCredentials) this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt'); if (this.vendor === 'cobalt' && !this.data.recognizer.model) {
// By default, application saves cobalt model in language
this.data.recognizer.model = cs.speechRecognizerLanguage;
}
if (!this.sttCredentials) {
try {
this.sttCredentials = await this._initSpeechCredentials(cs, this.vendor, this.label);
} catch (error) {
if (this.fallbackVendor && this.isHandledByPrimaryProvider) {
await this._fallback();
} else {
throw error;
}
}
}
/* when using cobalt model is required */
if (this.vendor === 'cobalt' && !this.data.recognizer.model) {
this.notifyError({ msg: 'ASR error', details:'Cobalt requires a model to be specified'});
throw new Error('Cobalt requires a model to be specified');
}
try { try {
if (!this.sttCredentials) {
const {writeAlerts, AlertType} = cs.srf.locals;
this.logger.info(`TaskTranscribe:exec - ERROR stt using ${this.vendor} requested but creds not supplied`);
writeAlerts({
account_sid: cs.accountSid,
alert_type: AlertType.STT_NOT_PROVISIONED,
vendor: this.vendor
}).catch((err) => this.logger.info({err}, 'Error generating alert for no stt'));
throw new Error('no 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) { if (this.separateRecognitionPerChannel && ep2) {
await this._startTranscribing(cs, ep2, 2); await this._startTranscribing(cs, ep2, 2);
@@ -125,8 +131,7 @@ class TaskTranscribe extends Task {
this.removeSpeechListeners(ep); this.removeSpeechListeners(ep);
} }
async kill(cs) { async _stopTranscription() {
super.kill(cs);
let stopTranscription = false; let stopTranscription = false;
if (this.ep?.connected) { if (this.ep?.connected) {
stopTranscription = true; stopTranscription = true;
@@ -138,6 +143,13 @@ class TaskTranscribe extends Task {
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'));
} }
return stopTranscription;
}
async kill(cs) {
super.kill(cs);
const stopTranscription = this._stopTranscription();
// hangup after 1 sec if we don't get a final transcription // hangup after 1 sec if we don't get a final transcription
if (stopTranscription) this._timer = setTimeout(() => this.notifyTaskDone(), 1500); if (stopTranscription) this._timer = setTimeout(() => this.notifyTaskDone(), 1500);
else this.notifyTaskDone(); else this.notifyTaskDone();
@@ -145,7 +157,26 @@ class TaskTranscribe extends Task {
await this.awaitTaskDone(); await this.awaitTaskDone();
} }
async _startTranscribing(cs, ep, channel) { async updateTranscribe(status) {
if (!this.killed && this.ep && this.ep.connected) {
this.logger.info(`TaskTranscribe:updateTranscribe status ${status}`);
switch (status) {
case TranscribeStatus.Pause:
await this._stopTranscription();
break;
case TranscribeStatus.Resume:
await this._startTranscribing(this.cs, this.ep, 1);
if (this.separateRecognitionPerChannel && this.ep2) {
await this._startTranscribing(this.cs, this.ep2, 2);
}
break;
}
}
}
async _setSpeechHandlers(cs, ep, channel) {
if (this[`_speechHandlersSet_${channel}`]) return;
this[`_speechHandlersSet_${channel}`] = true;
const opts = this.setChannelVarsForStt(this, this.sttCredentials, this.data.recognizer); const opts = this.setChannelVarsForStt(this, this.sttCredentials, this.data.recognizer);
switch (this.vendor) { switch (this.vendor) {
case 'google': case 'google':
@@ -183,8 +214,6 @@ class TaskTranscribe extends Task {
this._onStartOfSpeech.bind(this, cs, ep, channel)); this._onStartOfSpeech.bind(this, cs, ep, channel));
ep.addCustomEventListener(NuanceTranscriptionEvents.TranscriptionComplete, ep.addCustomEventListener(NuanceTranscriptionEvents.TranscriptionComplete,
this._onTranscriptionComplete.bind(this, cs, ep, channel)); this._onTranscriptionComplete.bind(this, cs, ep, channel));
ep.addCustomEventListener(AzureTranscriptionEvents.Error,
this._onNuanceError.bind(this, cs, ep, channel));
break; break;
case 'deepgram': case 'deepgram':
this.bugname = 'deepgram_transcribe'; this.bugname = 'deepgram_transcribe';
@@ -195,6 +224,40 @@ class TaskTranscribe extends Task {
ep.addCustomEventListener(DeepgramTranscriptionEvents.ConnectFailure, ep.addCustomEventListener(DeepgramTranscriptionEvents.ConnectFailure,
this._onDeepGramConnectFailure.bind(this, cs, ep, channel)); this._onDeepGramConnectFailure.bind(this, cs, ep, channel));
break; break;
case 'soniox':
this.bugname = 'soniox_transcribe';
ep.addCustomEventListener(SonioxTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep, channel));
break;
case 'cobalt':
this.bugname = 'cobalt_transcribe';
ep.addCustomEventListener(CobaltTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep, channel));
/* cobalt doesnt have language, it has model, which is required */
if (!this.data.recognizer.model) {
throw new Error('Cobalt requires a model to be specified');
}
this.language = this.data.recognizer.model;
/* special case: if using hints with cobalt we need to compile them */
this.hostport = opts.COBALT_SERVER_URI;
if (this.vendor === 'cobalt' && opts.COBALT_SPEECH_HINTS) {
try {
const context = await this.compileHintsForCobalt(
ep,
opts.COBALT_SERVER_URI,
this.data.recognizer.model,
opts.COBALT_CONTEXT_TOKEN,
opts.COBALT_SPEECH_HINTS
);
if (context) opts.COBALT_COMPILED_CONTEXT_DATA = context;
delete opts.COBALT_SPEECH_HINTS;
} catch (err) {
this.logger.error({err}, 'Error compiling hints for cobalt');
}
}
break;
case 'ibm': case 'ibm':
this.bugname = 'ibm_transcribe'; this.bugname = 'ibm_transcribe';
@@ -204,8 +267,6 @@ class TaskTranscribe extends Task {
this._onIbmConnect.bind(this, cs, ep, channel)); this._onIbmConnect.bind(this, cs, ep, channel));
ep.addCustomEventListener(IbmTranscriptionEvents.ConnectFailure, ep.addCustomEventListener(IbmTranscriptionEvents.ConnectFailure,
this._onIbmConnectFailure.bind(this, cs, ep, channel)); this._onIbmConnectFailure.bind(this, cs, ep, channel));
ep.addCustomEventListener(IbmTranscriptionEvents.Error,
this._onIbmError.bind(this, cs, ep, channel));
break; break;
case 'nvidia': case 'nvidia':
@@ -218,18 +279,38 @@ class TaskTranscribe extends Task {
this._onTranscriptionComplete.bind(this, cs, ep)); this._onTranscriptionComplete.bind(this, cs, ep));
ep.addCustomEventListener(NvidiaTranscriptionEvents.VadDetected, ep.addCustomEventListener(NvidiaTranscriptionEvents.VadDetected,
this._onVadDetected.bind(this, cs, ep)); this._onVadDetected.bind(this, cs, ep));
ep.addCustomEventListener(NvidiaTranscriptionEvents.Error,
this._onNvidiaError.bind(this, cs, ep));
break; break;
default: default:
throw new Error(`Invalid vendor ${this.vendor}`); if (this.vendor.startsWith('custom:')) {
this.bugname = `${this.vendor}_transcribe`;
ep.addCustomEventListener(JambonzTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep, channel));
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'));
}
async _startTranscribing(cs, ep, channel) {
await this._setSpeechHandlers(cs, ep, channel);
await this._transcribe(ep); await this._transcribe(ep);
/* start child span for this channel */
const {span, ctx} = this.startChildSpan(`${STT_LISTEN_SPAN_NAME}:${channel}`);
this.childSpan[channel - 1] = {span, ctx};
} }
async _transcribe(ep) { async _transcribe(ep) {
@@ -238,7 +319,8 @@ class TaskTranscribe extends Task {
interim: this.interim ? true : false, interim: this.interim ? true : false,
locale: this.language, locale: this.language,
channels: /*this.separateRecognitionPerChannel ? 2 : */ 1, channels: /*this.separateRecognitionPerChannel ? 2 : */ 1,
bugname: this.bugname bugname: this.bugname,
hostport: this.hostport
}); });
} }
@@ -253,8 +335,11 @@ class TaskTranscribe extends Task {
this.logger.debug({evt}, 'TaskTranscribe:_onTranscription - before normalization'); this.logger.debug({evt}, 'TaskTranscribe:_onTranscription - before normalization');
evt = this.normalizeTranscription(evt, this.vendor, channel, this.language); evt = this.normalizeTranscription(evt, this.vendor, channel, this.language);
this.logger.debug({evt}, 'TaskTranscribe:_onTranscription'); this.logger.debug({evt}, 'TaskTranscribe:_onTranscription');
if (evt.alternatives.length === 0) {
this.logger.info({evt}, 'TaskTranscribe:_onTranscription - got empty transcript, continue listening');
return;
}
if (evt.alternatives[0]?.transcript === '' && !cs.callGone && !this.killed) { if (evt.alternatives[0]?.transcript === '' && !cs.callGone && !this.killed) {
if (['microsoft', 'deepgram'].includes(this.vendor)) { if (['microsoft', 'deepgram'].includes(this.vendor)) {
@@ -267,6 +352,45 @@ class TaskTranscribe extends Task {
return; return;
} }
if (this.vendor === 'soniox') {
/* compile transcripts into one */
this._sonioxTranscripts.push(evt.vendor.finalWords);
if (evt.is_final) {
evt = this.compileSonioxTranscripts(this._sonioxTranscripts, 1, this.language);
this._sonioxTranscripts = [];
}
}
if (this.isContinuousAsr && evt.is_final) {
this._bufferedTranscripts.push(evt);
this._startAsrTimer(channel);
} else {
await this._resolve(channel, evt);
}
}
_compileTranscripts() {
assert(this._bufferedTranscripts.length);
const evt = this._bufferedTranscripts[0];
let t = '';
for (const a of this._bufferedTranscripts) {
t += ` ${a.alternatives[0].transcript}`;
}
evt.alternatives[0].transcript = t.trim();
return evt;
}
async _resolve(channel, evt) {
/* we've got a transcript, so end the otel child span for this channel */
if (this.childSpan[channel - 1] && this.childSpan[channel - 1].span) {
this.childSpan[channel - 1].span.setAttributes({
channel,
'stt.resolve': 'transcript',
'stt.result': JSON.stringify(evt)
});
this.childSpan[channel - 1].span.end();
}
if (this.transcriptionHook) { if (this.transcriptionHook) {
const b3 = this.getTracingPropagation(); const b3 = this.getTracingPropagation();
const httpHeaders = b3 && {b3}; const httpHeaders = b3 && {b3};
@@ -297,16 +421,44 @@ class TaskTranscribe extends Task {
this._clearTimer(); this._clearTimer();
this.notifyTaskDone(); this.notifyTaskDone();
} }
else {
/* start another child span for this channel */
const {span, ctx} = this.startChildSpan(`${STT_LISTEN_SPAN_NAME}:${channel}`);
this.childSpan[channel - 1] = {span, ctx};
}
} }
_onNoAudio(cs, ep, channel) { _onNoAudio(cs, ep, channel) {
this.logger.debug(`TaskTranscribe:_onNoAudio restarting transcription on channel ${channel}`); this.logger.debug(`TaskTranscribe:_onNoAudio restarting transcription on channel ${channel}`);
if (this.childSpan[channel - 1] && this.childSpan[channel - 1].span) {
this.childSpan[channel - 1].span.setAttributes({
channel,
'stt.resolve': 'timeout'
});
this.childSpan[channel - 1].span.end();
}
this._transcribe(ep); this._transcribe(ep);
/* start new child span for this channel */
const {span, ctx} = this.startChildSpan(`${STT_LISTEN_SPAN_NAME}:${channel}`);
this.childSpan[channel - 1] = {span, ctx};
} }
_onMaxDurationExceeded(cs, ep, channel) { _onMaxDurationExceeded(cs, ep, channel) {
this.logger.debug(`TaskTranscribe:_onMaxDurationExceeded restarting transcription on channel ${channel}`); this.logger.debug(`TaskTranscribe:_onMaxDurationExceeded restarting transcription on channel ${channel}`);
if (this.childSpan[channel - 1] && this.childSpan[channel - 1].span) {
this.childSpan[channel - 1].span.setAttributes({
channel,
'stt.resolve': 'max duration exceeded'
});
this.childSpan[channel - 1].span.end();
}
this._transcribe(ep); this._transcribe(ep);
/* start new child span for this channel */
const {span, ctx} = this.startChildSpan(`${STT_LISTEN_SPAN_NAME}:${channel}`);
this.childSpan[channel - 1] = {span, ctx};
} }
_clearTimer() { _clearTimer() {
@@ -315,25 +467,11 @@ class TaskTranscribe extends Task {
this._timer = null; this._timer = null;
} }
} }
_onNuanceError(_cs, _ep, _channel, evt) {
const {code, error, details} = evt;
if (code === 404 && error === 'No speech') {
this.logger.debug({code, error, details}, 'TaskTranscribe:_onNuanceError');
return this._resolve('timeout');
}
this.logger.info({code, error, details}, 'TaskTranscribe:_onNuanceError');
if (code === 413 && error === 'Too much speech') {
return this._resolve('timeout');
}
}
_onNvidiaError(cs, ep, evt) {
this.logger.info({evt}, 'TaskGather:_onNvidiaError');
}
_onDeepgramConnect(_cs, _ep) { _onDeepgramConnect(_cs, _ep) {
this.logger.debug('TaskTranscribe:_onDeepgramConnect'); this.logger.debug('TaskTranscribe:_onDeepgramConnect');
} }
_onDeepGramConnectFailure(cs, _ep, _channel, evt) { _onDeepGramConnectFailure(cs, _ep, channel, evt) {
const {reason} = evt; const {reason} = evt;
const {writeAlerts, AlertType} = cs.srf.locals; const {writeAlerts, AlertType} = cs.srf.locals;
this.logger.info({evt}, 'TaskTranscribe:_onDeepgramConnectFailure'); this.logger.info({evt}, 'TaskTranscribe:_onDeepgramConnectFailure');
@@ -344,6 +482,32 @@ class TaskTranscribe extends Task {
vendor: 'deepgram', vendor: 'deepgram',
}).catch((err) => this.logger.info({err}, 'Error generating alert for deepgram connection failure')); }).catch((err) => this.logger.info({err}, 'Error generating alert for deepgram connection failure'));
this.notifyError(`Failed connecting to speech vendor deepgram: ${reason}`); this.notifyError(`Failed connecting to speech vendor deepgram: ${reason}`);
if (this.childSpan[channel - 1] && this.childSpan[channel - 1].span) {
this.childSpan[channel - 1].span.setAttributes({
channel,
'stt.resolve': 'connection failure'
});
this.childSpan[channel - 1].span.end();
}
this.notifyTaskDone();
}
_onJambonzConnect(_cs, _ep) {
this.logger.debug('TaskTranscribe:_onJambonzConnect');
}
_onJambonzConnectFailure(cs, _ep, evt) {
const {reason} = evt;
const {writeAlerts, AlertType} = cs.srf.locals;
this.logger.info({evt}, 'TaskTranscribe:_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(); this.notifyTaskDone();
} }
@@ -351,7 +515,7 @@ class TaskTranscribe extends Task {
this.logger.debug('TaskTranscribe:_onIbmConnect'); this.logger.debug('TaskTranscribe:_onIbmConnect');
} }
_onIbmConnectFailure(cs, _ep, _channel, evt) { _onIbmConnectFailure(cs, _ep, channel, evt) {
const {reason} = evt; const {reason} = evt;
const {writeAlerts, AlertType} = cs.srf.locals; const {writeAlerts, AlertType} = cs.srf.locals;
this.logger.info({evt}, 'TaskTranscribe:_onIbmConnectFailure'); this.logger.info({evt}, 'TaskTranscribe:_onIbmConnectFailure');
@@ -362,13 +526,72 @@ class TaskTranscribe extends Task {
vendor: 'ibm', vendor: 'ibm',
}).catch((err) => this.logger.info({err}, 'Error generating alert for IBM connection failure')); }).catch((err) => this.logger.info({err}, 'Error generating alert for IBM connection failure'));
this.notifyError(`Failed connecting to speech vendor IBM: ${reason}`); this.notifyError(`Failed connecting to speech vendor IBM: ${reason}`);
if (this.childSpan[channel - 1] && this.childSpan[channel - 1].span) {
this.childSpan[channel - 1].span.setAttributes({
channel,
'stt.resolve': 'connection failure'
});
this.childSpan[channel - 1].span.end();
}
this.notifyTaskDone(); this.notifyTaskDone();
} }
_onIbmError(cs, _ep, _channel, evt) { _onIbmError(cs, _ep, _channel, evt) {
this.logger.info({evt}, 'TaskGather:_onIbmError'); this.logger.info({evt}, 'TaskTranscribe:_onIbmError');
}
async _onJambonzError(cs, _ep, evt) {
this.logger.info({evt}, 'TaskTranscribe:_onJambonzError');
if (this.isHandledByPrimaryProvider && this.fallbackVendor) {
_ep.stopTranscription({vendor: this.vendor})
.catch((err) => this.logger.error({err}, `Error stopping transcription for primary vendor ${this.vendor}`));
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
try {
await this._fallback();
let channel = 1;
if (this.ep !== _ep) {
channel = 2;
}
this._startTranscribing(cs, _ep, channel);
updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid);
return;
} catch (error) {
this.logger.info({error}, `There is error while falling back to ${this.fallbackVendor}`);
}
} else {
const {writeAlerts, AlertType} = cs.srf.locals;
if (this.vendor === 'nuance') {
const {code, error} = evt;
if (code === 404 && error === 'No speech') return this._resolve('timeout');
if (code === 413 && error === 'Too much speech') return this._resolve('timeout');
}
this.logger.info({evt}, 'TaskTranscribe:_onJambonzError');
writeAlerts({
account_sid: cs.accountSid,
alert_type: AlertType.STT_FAILURE,
message: `Custom speech vendor ${this.vendor} error: ${evt.error}`,
vendor: this.vendor,
}).catch((err) => this.logger.info({err}, 'Error generating alert for jambonz custom connection failure'));
this.notifyError({msg: 'ASR error', details:`Custom speech vendor ${this.vendor} error: ${evt.error}`});
}
} }
_startAsrTimer(channel) {
assert(this.isContinuousAsr);
this._clearAsrTimer(channel);
this._asrTimer = setTimeout(() => {
this.logger.debug(`TaskTranscribe:_startAsrTimer - asr timer went off for channel: ${channel}`);
const evt = this._compileTranscripts();
this._bufferedTranscripts = [];
this._resolve(channel, evt);
}, this.asrTimeout);
this.logger.debug(`TaskTranscribe:_startAsrTimer: set for ${this.asrTimeout}ms for channel ${channel}`);
}
_clearAsrTimer(channel) {
if (this._asrTimer) clearTimeout(this._asrTimer);
this._asrTimer = null;
}
} }
module.exports = TaskTranscribe; module.exports = TaskTranscribe;

View File

@@ -1,14 +1,22 @@
const Emitter = require('events'); const Emitter = require('events');
const {readFile} = require('fs'); const {readFile} = require('fs');
const { const {
TaskName,
GoogleTranscriptionEvents, GoogleTranscriptionEvents,
AwsTranscriptionEvents, AwsTranscriptionEvents,
AzureTranscriptionEvents, AzureTranscriptionEvents,
NuanceTranscriptionEvents,
NvidiaTranscriptionEvents,
IbmTranscriptionEvents,
SonioxTranscriptionEvents,
CobaltTranscriptionEvents,
DeepgramTranscriptionEvents,
JambonzTranscriptionEvents,
AmdEvents, AmdEvents,
AvmdEvents AvmdEvents
} = require('./constants'); } = require('./constants');
const bugname = 'amd_bug'; const bugname = 'amd_bug';
const {VMD_HINTS_FILE} = process.env; const {VMD_HINTS_FILE} = require('../config');
let voicemailHints = []; let voicemailHints = [];
const updateHints = async(file, callback) => { const updateHints = async(file, callback) => {
@@ -47,13 +55,19 @@ class Amd extends Emitter {
this.language = opts.recognizer?.language || cs.speechRecognizerLanguage; this.language = opts.recognizer?.language || cs.speechRecognizerLanguage;
if ('default' === this.language) this.language = cs.speechRecognizerLanguage; if ('default' === this.language) this.language = cs.speechRecognizerLanguage;
this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt'); this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt',
opts.recognizer?.label || cs.speechRecognizerLabel);
if (!this.sttCredentials) throw new Error(`No speech credentials found for vendor ${this.vendor}`); if (!this.sttCredentials) throw new Error(`No speech credentials found for vendor ${this.vendor}`);
this.thresholdWordCount = opts.thresholdWordCount || 9; this.thresholdWordCount = opts.thresholdWordCount || 9;
const {normalizeTranscription} = require('./transcription-utils')(logger); const {normalizeTranscription} = require('./transcription-utils')(logger);
this.normalizeTranscription = normalizeTranscription; this.normalizeTranscription = normalizeTranscription;
const {getNuanceAccessToken, getIbmAccessToken} = cs.srf.locals.dbHelpers;
this.getNuanceAccessToken = getNuanceAccessToken;
this.getIbmAccessToken = getIbmAccessToken;
const {setChannelVarsForStt} = require('./transcription-utils')(logger);
this.setChannelVarsForStt = setChannelVarsForStt;
const { const {
noSpeechTimeoutMs = 5000, noSpeechTimeoutMs = 5000,
@@ -184,7 +198,7 @@ module.exports = (logger) => {
const {vendor, language} = ep.amd; const {vendor, language} = ep.amd;
ep.startTranscription({ ep.startTranscription({
vendor, vendor,
language, locale: language,
interim: true, interim: true,
bugname bugname
}).catch((err) => { }).catch((err) => {
@@ -229,52 +243,96 @@ module.exports = (logger) => {
const startAmd = async(cs, ep, task, opts) => { const startAmd = async(cs, ep, task, opts) => {
const amd = ep.amd = new Amd(logger, cs, opts); const amd = ep.amd = new Amd(logger, cs, opts);
const {vendor, language, sttCredentials} = amd; const {vendor, language} = amd;
const sttOpts = {}; let sttCredentials = amd.sttCredentials;
const hints = voicemailHints[language] || []; const hints = voicemailHints[language] || [];
if (vendor === 'nuance' && sttCredentials.client_id) {
/* get nuance access token */
const {getNuanceAccessToken} = amd;
const {client_id, secret} = sttCredentials;
const {access_token, servedFromCache} = await getNuanceAccessToken(client_id, secret, 'asr tts');
logger.debug({client_id}, `Gather:exec - got nuance access token ${servedFromCache ? 'from cache' : ''}`);
sttCredentials = {...sttCredentials, access_token};
}
else if (vendor == 'ibm' && sttCredentials.stt_api_key) {
/* get ibm access token */
const {getIbmAccessToken} = amd;
const {stt_api_key, stt_region} = sttCredentials;
const {access_token, servedFromCache} = await getIbmAccessToken(stt_api_key);
logger.debug({stt_api_key}, `Gather:exec - got ibm access token ${servedFromCache ? 'from cache' : ''}`);
sttCredentials = {...sttCredentials, access_token, stt_region};
}
/* set stt options */ /* set stt options */
logger.info(`starting amd for vendor ${vendor} and language ${language}`); logger.info(`starting amd for vendor ${vendor} and language ${language}`);
if ('google' === vendor) { const sttOpts = amd.setChannelVarsForStt({name: TaskName.Gather}, sttCredentials, {
sttOpts.GOOGLE_APPLICATION_CREDENTIALS = JSON.stringify(sttCredentials.credentials); vendor,
sttOpts.GOOGLE_SPEECH_USE_ENHANCED = true; hints,
sttOpts.GOOGLE_SPEECH_HINTS = hints.join(','); enhancedModel: true,
if (opts.recognizer?.altLanguages) { altLanguages: opts.recognizer?.altLanguages || [],
sttOpts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = opts.recognizer.altLanguages.join(','); initialSpeechTimeoutMs: opts.resolveTimeoutMs,
} });
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, onTranscription.bind(null, cs, ep, task));
ep.addCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance, onEndOfUtterance.bind(null, cs, ep, task));
}
else if (['aws', 'polly'].includes(vendor)) {
Object.assign(sttOpts, {
AWS_ACCESS_KEY_ID: sttCredentials.accessKeyId,
AWS_SECRET_ACCESS_KEY: sttCredentials.secretAccessKey,
AWS_REGION: sttCredentials.region
});
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, onTranscription.bind(null, cs, ep, task));
}
else if ('microsoft' === vendor) {
Object.assign(sttOpts, {
'AZURE_SUBSCRIPTION_KEY': sttCredentials.api_key,
'AZURE_REGION': sttCredentials.region
});
sttOpts.AZURE_SPEECH_HINTS = hints.join(',');
if (opts.recognizer?.altLanguages) {
sttOpts.AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = opts.recognizer.altLanguages.join(',');
}
sttOpts.AZURE_INITIAL_SPEECH_TIMEOUT_MS = opts.resolveTimeoutMs || 20000;
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription, onTranscription.bind(null, cs, ep, task));
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected, onNoSpeechDetected.bind(null, cs, ep, task));
}
logger.debug({sttOpts}, 'startAmd: setting channel vars');
await ep.set(sttOpts).catch((err) => logger.info(err, 'Error setting channel variables')); await ep.set(sttOpts).catch((err) => logger.info(err, 'Error setting channel variables'));
amd.transcriptionHandler = onTranscription.bind(null, cs, ep, task);
amd.EndOfUtteranceHandler = onEndOfUtterance.bind(null, cs, ep, task);
amd.noSpeechHandler = onNoSpeechDetected.bind(null, cs, ep, task);
switch (vendor) {
case 'google':
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, amd.transcriptionHandler);
ep.addCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance, amd.EndOfUtteranceHandler);
break;
case 'aws':
case 'polly':
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, amd.transcriptionHandler);
break;
case 'microsoft':
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription, amd.transcriptionHandler);
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected, amd.noSpeechHandler);
break;
case 'nuance':
ep.addCustomEventListener(NuanceTranscriptionEvents.Transcription, amd.transcriptionHandler);
break;
case 'deepgram':
ep.addCustomEventListener(DeepgramTranscriptionEvents.Transcription, amd.transcriptionHandler);
break;
case 'soniox':
amd.bugname = 'soniox_amd_transcribe';
ep.addCustomEventListener(SonioxTranscriptionEvents.Transcription, amd.transcriptionHandler);
break;
case 'ibm':
ep.addCustomEventListener(IbmTranscriptionEvents.Transcription, amd.transcriptionHandler);
break;
case 'nvidia':
ep.addCustomEventListener(NvidiaTranscriptionEvents.Transcription, amd.transcriptionHandler);
break;
case 'cobalt':
ep.addCustomEventListener(CobaltTranscriptionEvents.Transcription, amd.transcriptionHandler);
break;
default:
if (vendor.startsWith('custom:')) {
ep.addCustomEventListener(JambonzTranscriptionEvents.Transcription, amd.transcriptionHandler);
break;
}
else {
throw new Error(`Invalid vendor ${this.vendor}`);
}
}
amd amd
.on(AmdEvents.NoSpeechDetected, (evt) => { .on(AmdEvents.NoSpeechDetected, (evt) => {
task.emit('amd', {type: AmdEvents.NoSpeechDetected, ...evt}); task.emit('amd', {type: AmdEvents.NoSpeechDetected, ...evt});
try { try {
ep.connected && ep.stopTranscription({vendor, bugname}); stopAmd(ep, task);
} catch (err) { } catch (err) {
logger.info({err}, 'Error stopping transcription'); logger.info({err}, 'Error stopping transcription');
} }
@@ -282,7 +340,7 @@ module.exports = (logger) => {
.on(AmdEvents.HumanDetected, (evt) => { .on(AmdEvents.HumanDetected, (evt) => {
task.emit('amd', {type: AmdEvents.HumanDetected, ...evt}); task.emit('amd', {type: AmdEvents.HumanDetected, ...evt});
try { try {
ep.connected && ep.stopTranscription({vendor, bugname}); stopAmd(ep, task);
} catch (err) { } catch (err) {
logger.info({err}, 'Error stopping transcription'); logger.info({err}, 'Error stopping transcription');
} }
@@ -293,7 +351,7 @@ module.exports = (logger) => {
.on(AmdEvents.DecisionTimeout, (evt) => { .on(AmdEvents.DecisionTimeout, (evt) => {
task.emit('amd', {type: AmdEvents.DecisionTimeout, ...evt}); task.emit('amd', {type: AmdEvents.DecisionTimeout, ...evt});
try { try {
ep.connected && ep.stopTranscription({vendor, bugname}); stopAmd(ep, task);
} catch (err) { } catch (err) {
logger.info({err}, 'Error stopping transcription'); logger.info({err}, 'Error stopping transcription');
} }
@@ -301,7 +359,7 @@ module.exports = (logger) => {
.on(AmdEvents.ToneTimeout, (evt) => { .on(AmdEvents.ToneTimeout, (evt) => {
//task.emit('amd', {type: AmdEvents.ToneTimeout, ...evt}); //task.emit('amd', {type: AmdEvents.ToneTimeout, ...evt});
try { try {
ep.connected && ep.execute('avmd_stop').catch((err) => logger.info(err, 'Error stopping avmd')); stopAmd(ep, task);
} catch (err) { } catch (err) {
logger.info({err}, 'Error stopping avmd'); logger.info({err}, 'Error stopping avmd');
} }
@@ -309,7 +367,7 @@ module.exports = (logger) => {
.on(AmdEvents.MachineStoppedSpeaking, () => { .on(AmdEvents.MachineStoppedSpeaking, () => {
task.emit('amd', {type: AmdEvents.MachineStoppedSpeaking}); task.emit('amd', {type: AmdEvents.MachineStoppedSpeaking});
try { try {
ep.connected && ep.stopTranscription({vendor, bugname}); stopAmd(ep, task);
} catch (err) { } catch (err) {
logger.info({err}, 'Error stopping transcription'); logger.info({err}, 'Error stopping transcription');
} }
@@ -328,6 +386,19 @@ module.exports = (logger) => {
if (ep.amd) { if (ep.amd) {
vendor = ep.amd.vendor; vendor = ep.amd.vendor;
ep.amd.stopAllTimers(); ep.amd.stopAllTimers();
ep.removeListener(GoogleTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(GoogleTranscriptionEvents.EndOfUtterance, ep.amd.EndOfUtteranceHandler);
ep.removeListener(AwsTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(AzureTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(AzureTranscriptionEvents.NoSpeechDetected, ep.amd.noSpeechHandler);
ep.removeListener(NuanceTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(DeepgramTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(SonioxTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(IbmTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(NvidiaTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(JambonzTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.amd = null; ep.amd = null;
} }

View File

@@ -1,20 +1,30 @@
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 || 3010; const {
AWS_REGION,
AWS_SNS_PORT: PORT,
AWS_SNS_TOPIC_ARM,
AWS_SNS_PORT_MAX,
} = require('../config');
const {LifeCycleEvents} = require('./constants'); const {LifeCycleEvents} = require('./constants');
const express = require('express'); const express = require('express');
const app = express(); const app = express();
const getString = bent('string'); const getString = bent('string');
const AWS = require('aws-sdk'); const {
const sns = new AWS.SNS({apiVersion: '2010-03-31'}); SNSClient,
const autoscaling = new AWS.AutoScaling({apiVersion: '2011-01-01'}); SubscribeCommand,
UnsubscribeCommand } = require('@aws-sdk/client-sns');
const snsClient = new SNSClient({ region: AWS_REGION, apiVersion: '2010-03-31' });
const {
AutoScalingClient,
DescribeAutoScalingGroupsCommand,
CompleteLifecycleActionCommand } = require('@aws-sdk/client-auto-scaling');
const autoScalingClient = new AutoScalingClient({ region: AWS_REGION, apiVersion: '2011-01-01' });
const {Parser} = require('xml2js'); const {Parser} = require('xml2js');
const parser = new Parser(); const parser = new Parser();
const {validatePayload} = require('verify-aws-sns-signature'); const {validatePayload} = require('verify-aws-sns-signature');
AWS.config.update({region: process.env.AWS_REGION});
class SnsNotifier extends Emitter { class SnsNotifier extends Emitter {
constructor(logger) { constructor(logger) {
super(); super();
@@ -31,8 +41,8 @@ class SnsNotifier extends Emitter {
_handleErrors(logger, app, resolve, reject, e) { _handleErrors(logger, app, resolve, reject, e) {
if (e.code === 'EADDRINUSE' && if (e.code === 'EADDRINUSE' &&
process.env.AWS_SNS_PORT_MAX && AWS_SNS_PORT_MAX &&
e.port < process.env.AWS_SNS_PORT_MAX) { e.port < AWS_SNS_PORT_MAX) {
logger.info(`SNS lifecycle server failed to bind port on ${e.port}, will try next port`); 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); const server = this._doListen(logger, app, ++e.port, resolve);
@@ -64,7 +74,7 @@ class SnsNotifier extends Emitter {
subscriptionRequestId: this.subscriptionRequestId subscriptionRequestId: this.subscriptionRequestId
}, 'response from SNS SubscribeURL'); }, 'response from SNS SubscribeURL');
const data = await this.describeInstance(); const data = await this.describeInstance();
this.lifecycleState = data.AutoScalingInstances[0].LifecycleState; this.lifecycleState = data.AutoScalingGroups[0].Instances[0].LifecycleState;
this.emit('SubscriptionConfirmation', {publicIp: this.publicIp}); this.emit('SubscriptionConfirmation', {publicIp: this.publicIp});
break; break;
@@ -130,51 +140,56 @@ class SnsNotifier extends Emitter {
async subscribe() { async subscribe() {
try { try {
const response = await sns.subscribe({ const params = {
Protocol: 'http', Protocol: 'http',
TopicArn: process.env.AWS_SNS_TOPIC_ARM, TopicArn: AWS_SNS_TOPIC_ARM,
Endpoint: this.snsEndpoint Endpoint: this.snsEndpoint
}).promise(); };
this.logger.info({response}, `response to SNS subscribe to ${process.env.AWS_SNS_TOPIC_ARM}`); const response = await snsClient.send(new SubscribeCommand(params));
this.logger.info({response}, `response to SNS subscribe to ${AWS_SNS_TOPIC_ARM}`);
} catch (err) { } catch (err) {
this.logger.error({err}, `Error subscribing to SNS topic arn ${process.env.AWS_SNS_TOPIC_ARM}`); this.logger.error({err}, `Error subscribing to SNS topic arn ${AWS_SNS_TOPIC_ARM}`);
} }
} }
async unsubscribe() { async unsubscribe() {
if (!this.subscriptionArn) throw new Error('SnsNotifier#unsubscribe called without an active subscription'); if (!this.subscriptionArn) throw new Error('SnsNotifier#unsubscribe called without an active subscription');
try { try {
const response = await sns.unsubscribe({ const params = {
SubscriptionArn: this.subscriptionArn SubscriptionArn: this.subscriptionArn
}).promise(); };
this.logger.info({response}, `response to SNS unsubscribe to ${process.env.AWS_SNS_TOPIC_ARM}`); const response = await snsClient.send(new UnsubscribeCommand(params));
this.logger.info({response}, `response to SNS unsubscribe to ${AWS_SNS_TOPIC_ARM}`);
} catch (err) { } catch (err) {
this.logger.error({err}, `Error unsubscribing to SNS topic arn ${process.env.AWS_SNS_TOPIC_ARM}`); this.logger.error({err}, `Error unsubscribing to SNS topic arn ${AWS_SNS_TOPIC_ARM}`);
} }
} }
completeScaleIn() { completeScaleIn() {
assert(this.scaleInParams); assert(this.scaleInParams);
autoscaling.completeLifecycleAction(this.scaleInParams, (err, response) => { autoScalingClient.send(new CompleteLifecycleActionCommand(this.scaleInParams))
if (err) return this.logger.error({err}, 'Error completing scale-in'); .then((data) => {
this.logger.info({response}, 'Successfully completed scale-in action'); return this.logger.info({data}, 'Successfully completed scale-in action');
}); })
.catch((err) => {
this.logger.error({err}, 'Error completing scale-in');
});
} }
describeInstance() { describeInstance() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!this.instanceId) return reject('instance-id unknown'); if (!this.instanceId) return reject('instance-id unknown');
autoscaling.describeAutoScalingInstances({ autoScalingClient.send(new DescribeAutoScalingGroupsCommand({
InstanceIds: [this.instanceId] InstanceIds: [this.instanceId]
}, (err, data) => { }))
if (err) { .then((data) => {
this.logger.info({data}, 'SnsNotifier: describeInstance');
return resolve(data);
})
.catch((err) => {
this.logger.error({err}, 'Error describing instances'); this.logger.error({err}, 'Error describing instances');
reject(err); reject(err);
} else { });
this.logger.info({data}, 'SnsNotifier: describeInstance');
resolve(data);
}
});
}); });
} }
@@ -188,7 +203,7 @@ module.exports = async function(logger) {
process.on('SIGHUP', async() => { process.on('SIGHUP', async() => {
try { try {
const data = await notifier.describeInstance(); const data = await notifier.describeInstance();
const state = data.AutoScalingInstances[0].LifecycleState; const state = data.AutoScalingGroups[0].Instances[0].LifecycleState;
if (state !== notifier.lifecycleState) { if (state !== notifier.lifecycleState) {
notifier.lifecycleState = state; notifier.lifecycleState = state;
switch (state) { switch (state) {

View File

@@ -2,6 +2,7 @@ const assert = require('assert');
const Emitter = require('events'); const Emitter = require('events');
const crypto = require('crypto'); const crypto = require('crypto');
const timeSeries = require('@jambonz/time-series'); const timeSeries = require('@jambonz/time-series');
const {NODE_ENV, JAMBONES_TIME_SERIES_HOST} = require('../config');
let alerter ; let alerter ;
class BaseRequestor extends Emitter { class BaseRequestor extends Emitter {
@@ -22,9 +23,9 @@ class BaseRequestor extends Emitter {
if (!alerter) { if (!alerter) {
alerter = timeSeries(logger, { alerter = timeSeries(logger, {
host: process.env.JAMBONES_TIME_SERIES_HOST, host: JAMBONES_TIME_SERIES_HOST,
commitSize: 50, commitSize: 50,
commitInterval: 'test' === process.env.NODE_ENV ? 7 : 20 commitInterval: 'test' === NODE_ENV ? 7 : 20
}); });
} }
} }

View File

@@ -2,17 +2,24 @@ const {context, trace} = require('@opentelemetry/api');
const {Dialog} = require('drachtio-srf'); const {Dialog} = require('drachtio-srf');
class RootSpan { class RootSpan {
constructor(callType, req) { constructor(callType, req) {
let tracer, callSid, linkedSpanId; const {srf} = require('../../');
const tracer = srf.locals.otel.tracer;
let callSid, accountSid, applicationSid, linkedSpanId;
if (req instanceof Dialog) { if (req instanceof Dialog) {
const dlg = req; const dlg = req;
tracer = dlg.srf.locals.otel.tracer;
callSid = dlg.callSid; callSid = dlg.callSid;
linkedSpanId = dlg.linkedSpanId; linkedSpanId = dlg.linkedSpanId;
} }
else { else if (req.srf) {
tracer = req.srf.locals.otel.tracer;
callSid = req.locals.callSid; callSid = req.locals.callSid;
accountSid = req.get('X-Account-Sid'),
applicationSid = req.locals.application_sid;
}
else {
callSid = req.callSid;
accountSid = req.accountSid;
applicationSid = req.applicationSid;
} }
this._span = tracer.startSpan(callType || 'incoming-call'); this._span = tracer.startSpan(callType || 'incoming-call');
if (req instanceof Dialog) { if (req instanceof Dialog) {
@@ -22,13 +29,20 @@ class RootSpan {
callId: dlg.sip.callId callId: dlg.sip.callId
}); });
} }
else if (req.srf) {
this._span.setAttributes({
callSid,
accountSid,
applicationSid,
callId: req.get('Call-ID'),
externalCallId: req.get('X-CID')
});
}
else { else {
this._span.setAttributes({ this._span.setAttributes({
callSid, callSid,
accountSid: req.get('X-Account-Sid'), accountSid,
applicationSid: req.locals.application_sid, applicationSid
callId: req.get('Call-ID'),
externalCallId: req.get('X-CID')
}); });
} }

View File

@@ -29,7 +29,7 @@
"Tag": "tag", "Tag": "tag",
"Transcribe": "transcribe" "Transcribe": "transcribe"
}, },
"AllowedSipRecVerbs": ["config", "gather", "transcribe", "listen"], "AllowedSipRecVerbs": ["config", "gather", "transcribe", "listen", "tag"],
"CallStatus": { "CallStatus": {
"Trying": "trying", "Trying": "trying",
"Ringing": "ringing", "Ringing": "ringing",
@@ -51,6 +51,11 @@
"Silence": "silence", "Silence": "silence",
"Resume": "resume" "Resume": "resume"
}, },
"TranscribeStatus": {
"Pause": "pause",
"Silence": "silence",
"Resume": "resume"
},
"TaskPreconditions": { "TaskPreconditions": {
"None": "none", "None": "none",
"Endpoint": "endpoint", "Endpoint": "endpoint",
@@ -86,6 +91,15 @@
"ConnectFailure": "deepgram_transcribe::connect_failed", "ConnectFailure": "deepgram_transcribe::connect_failed",
"Connect": "deepgram_transcribe::connect" "Connect": "deepgram_transcribe::connect"
}, },
"SonioxTranscriptionEvents": {
"Transcription": "soniox_transcribe::transcription",
"Error": "soniox_transcribe::error"
},
"CobaltTranscriptionEvents": {
"Transcription": "cobalt_speech::transcription",
"CompileContext": "cobalt_speech::compile_context_response",
"Error": "cobalt_speech::error"
},
"IbmTranscriptionEvents": { "IbmTranscriptionEvents": {
"Transcription": "ibm_transcribe::transcription", "Transcription": "ibm_transcribe::transcription",
"ConnectFailure": "ibm_transcribe::connect_failed", "ConnectFailure": "ibm_transcribe::connect_failed",
@@ -106,6 +120,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",
@@ -147,6 +167,7 @@
"queue:status", "queue:status",
"dial:confirm", "dial:confirm",
"verb:hook", "verb:hook",
"verb:status",
"jambonz:error" "jambonz:error"
], ],
"RecordState": { "RecordState": {

View File

@@ -1,19 +1,24 @@
const {execSync} = require('child_process'); const {execSync} = require('child_process');
const {
JAMBONES_FREESWITCH,
NODE_ENV,
JAMBONES_FREESWITCH_MAX_CALL_DURATION_MINS,
} = require('../config');
const now = Date.now(); const now = Date.now();
const fsInventory = process.env.JAMBONES_FREESWITCH const fsInventory = JAMBONES_FREESWITCH
.split(',') .split(',')
.map((fs) => { .map((fs) => {
const arr = /^([^:]*):([^:]*):([^:]*)(?::([^:]*))?/.exec(fs); const arr = /^([^:]*):([^:]*):([^:]*)(?::([^:]*))?/.exec(fs);
const opts = {address: arr[1], port: arr[2], secret: arr[3]}; const opts = {address: arr[1], port: arr[2], secret: arr[3]};
if (arr.length > 4) opts.advertisedAddress = arr[4]; if (arr.length > 4) opts.advertisedAddress = arr[4];
if (process.env.NODE_ENV === 'test') opts.listenAddress = '0.0.0.0'; if (NODE_ENV === 'test') opts.listenAddress = '0.0.0.0';
return opts; return opts;
}); });
const clearChannels = () => { const clearChannels = () => {
const {logger} = require('../..'); const {logger} = require('../..');
const pwd = fsInventory[0].secret; const pwd = fsInventory[0].secret;
const maxDurationMins = process.env.JAMBONES_FREESWITCH_MAX_CALL_DURATION_MINS || 180; const maxDurationMins = JAMBONES_FREESWITCH_MAX_CALL_DURATION_MINS;
const calls = execSync(`/usr/local/freeswitch/bin/fs_cli -p ${pwd} -x "show calls"`, {encoding: 'utf8'}) const calls = execSync(`/usr/local/freeswitch/bin/fs_cli -p ${pwd} -x "show calls"`, {encoding: 'utf8'})
.split('\n') .split('\n')

View File

@@ -3,13 +3,10 @@ const {decrypt} = require('./encrypt-decrypt');
const sqlAccountDetails = `SELECT * const sqlAccountDetails = `SELECT *
FROM accounts account FROM accounts account
WHERE account.account_sid = ?`; WHERE account.account_sid = ?`;
const sqlSpeechCredentials = `SELECT * const sqlSpeechCredentialsForAccount = `SELECT *
FROM speech_credentials FROM speech_credentials
WHERE account_sid = ? `; WHERE account_sid = ? OR (account_sid is NULL AND service_provider_sid =
const sqlSpeechCredentialsForSP = `SELECT * (SELECT service_provider_sid from accounts where account_sid = ?))`;
FROM speech_credentials
WHERE service_provider_sid =
(SELECT service_provider_sid from accounts where account_sid = ?)`;
const sqlQueryAccountCarrierByName = `SELECT voip_carrier_sid const sqlQueryAccountCarrierByName = `SELECT voip_carrier_sid
FROM voip_carriers vc FROM voip_carriers vc
WHERE vc.account_sid = ? WHERE vc.account_sid = ?
@@ -20,6 +17,16 @@ WHERE vc.account_sid IS NULL
AND vc.service_provider_sid = AND vc.service_provider_sid =
(SELECT service_provider_sid from accounts where account_sid = ?) (SELECT service_provider_sid from accounts where account_sid = ?)
AND vc.name = ?`; AND vc.name = ?`;
const sqlQueryAccountPhoneNumber = `SELECT voip_carrier_sid
FROM phone_numbers pn
WHERE pn.account_sid = ?
AND pn.number = ?`;
const sqlQuerySPPhoneNumber = `SELECT voip_carrier_sid
FROM phone_numbers pn
WHERE pn.account_sid IS NULL
AND pn.service_provider_sid =
(SELECT service_provider_sid from accounts where account_sid = ?)
AND pn.number = ?`;
const speechMapper = (cred) => { const speechMapper = (cred) => {
const {credential, ...obj} = cred; const {credential, ...obj} = cred;
@@ -39,8 +46,10 @@ const speechMapper = (cred) => {
obj.region = o.region; obj.region = o.region;
obj.use_custom_stt = o.use_custom_stt; obj.use_custom_stt = o.use_custom_stt;
obj.custom_stt_endpoint = o.custom_stt_endpoint; obj.custom_stt_endpoint = o.custom_stt_endpoint;
obj.custom_stt_endpoint_url = o.custom_stt_endpoint_url;
obj.use_custom_tts = o.use_custom_tts; obj.use_custom_tts = o.use_custom_tts;
obj.custom_tts_endpoint = o.custom_tts_endpoint; obj.custom_tts_endpoint = o.custom_tts_endpoint;
obj.custom_tts_endpoint_url = o.custom_tts_endpoint_url;
} }
else if ('wellsaid' === obj.vendor) { else if ('wellsaid' === obj.vendor) {
const o = JSON.parse(decrypt(credential)); const o = JSON.parse(decrypt(credential));
@@ -50,6 +59,8 @@ const speechMapper = (cred) => {
const o = JSON.parse(decrypt(credential)); const o = JSON.parse(decrypt(credential));
obj.client_id = o.client_id; obj.client_id = o.client_id;
obj.secret = o.secret; obj.secret = o.secret;
obj.nuance_tts_uri = o.nuance_tts_uri;
obj.nuance_stt_uri = o.nuance_stt_uri;
} }
else if ('ibm' === obj.vendor) { else if ('ibm' === obj.vendor) {
const o = JSON.parse(decrypt(credential)); const o = JSON.parse(decrypt(credential));
@@ -62,67 +73,52 @@ const speechMapper = (cred) => {
const o = JSON.parse(decrypt(credential)); const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key; obj.api_key = o.api_key;
} }
else if ('soniox' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
}
else if ('nvidia' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.riva_server_uri = o.riva_server_uri;
}
else if ('cobalt' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.cobalt_server_uri = o.cobalt_server_uri;
}
else if (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) { } catch (err) {
console.log(err); console.log(err);
} }
return obj; return obj;
}; };
const bucketCredentialDecrypt = (account) => {
const { bucket_credential } = account.account;
if (!bucket_credential || bucket_credential.vendor) return;
account.account.bucket_credential = JSON.parse(decrypt(bucket_credential));
};
module.exports = (logger, srf) => { module.exports = (logger, srf) => {
const {pool} = srf.locals.dbHelpers; const {pool} = srf.locals.dbHelpers;
const pp = pool.promise(); const pp = pool.promise();
const lookupAccountDetails = async(account_sid) => { const lookupAccountDetails = async(account_sid) => {
const [r] = await pp.query({sql: sqlAccountDetails, nestTables: true}, account_sid); const [r] = await pp.query({sql: sqlAccountDetails, nestTables: true}, [account_sid]);
if (0 === r.length) throw new Error(`invalid accountSid: ${account_sid}`); if (0 === r.length) throw new Error(`invalid accountSid: ${account_sid}`);
const [r2] = await pp.query(sqlSpeechCredentials, account_sid); const [r2] = await pp.query(sqlSpeechCredentialsForAccount, [account_sid, 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 */ const account = r[0];
const haveGoogle = speech.find((s) => s.vendor === 'google'); bucketCredentialDecrypt(account);
const haveAws = speech.find((s) => s.vendor === 'aws');
const haveMicrosoft = speech.find((s) => s.vendor === 'microsoft');
const haveWellsaid = speech.find((s) => s.vendor === 'wellsaid');
const haveNuance = speech.find((s) => s.vendor === 'nuance');
const haveDeepgram = speech.find((s) => s.vendor === 'deepgram');
const haveIbm = speech.find((s) => s.vendor === 'ibm');
if (!haveGoogle || !haveAws || !haveMicrosoft || !haveWellsaid || !haveNuance || !haveIbm || !haveDeepgram) {
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));
}
if (!haveNuance) {
const nuance = r3.find((s) => s.vendor === 'nuance');
if (nuance) speech.push(speechMapper(nuance));
}
if (!haveDeepgram) {
const deepgram = r3.find((s) => s.vendor === 'deepgram');
if (deepgram) speech.push(speechMapper(deepgram));
}
if (!haveIbm) {
const ibm = r3.find((s) => s.vendor === 'ibm');
if (ibm) speech.push(speechMapper(ibm));
}
}
}
return { return {
...r[0], ...account,
speech speech
}; };
}; };
@@ -150,9 +146,22 @@ module.exports = (logger, srf) => {
} }
}; };
const lookupCarrierByPhoneNumber = async(account_sid, phoneNumber) => {
const pp = pool.promise();
try {
const [r] = await pp.query(sqlQueryAccountPhoneNumber, [account_sid, phoneNumber]);
if (r.length) return r[0].voip_carrier_sid;
const [r2] = await pp.query(sqlQuerySPPhoneNumber, [account_sid, phoneNumber]);
if (r2.length) return r2[0].voip_carrier_sid;
} catch (err) {
logger.error({err}, `lookupPhoneNumber: Error ${account_sid}:${phoneNumber}`);
}
};
return { return {
lookupAccountDetails, lookupAccountDetails,
updateSpeechCredentialLastUsed, updateSpeechCredentialLastUsed,
lookupCarrier lookupCarrier,
lookupCarrierByPhoneNumber
}; };
}; };

View File

@@ -1,10 +1,11 @@
const crypto = require('crypto'); const crypto = require('crypto');
const algorithm = process.env.LEGACY_CRYPTO ? 'aes-256-ctr' : 'aes-256-cbc'; const {LEGACY_CRYPTO, ENCRYPTION_SECRET, JWT_SECRET} = require('../config');
const algorithm = LEGACY_CRYPTO ? 'aes-256-ctr' : 'aes-256-cbc';
const iv = crypto.randomBytes(16); const iv = crypto.randomBytes(16);
const secretKey = crypto.createHash('sha256') const secretKey = crypto.createHash('sha256')
.update(String(process.env.JWT_SECRET)) .update(ENCRYPTION_SECRET || JWT_SECRET)
.digest('base64') .digest('base64')
.substr(0, 32); .substring(0, 32);
const encrypt = (text) => { const encrypt = (text) => {
const cipher = crypto.createCipheriv(algorithm, secretKey, iv); const cipher = crypto.createCipheriv(algorithm, secretKey, iv);
@@ -25,8 +26,8 @@ const decrypt = (data) => {
throw err; throw err;
} }
const decipher = crypto.createDecipheriv(algorithm, secretKey, Buffer.from(hash.iv, 'hex')); const decipher = crypto.createDecipheriv(algorithm, secretKey, Buffer.from(hash.iv, 'hex'));
const decrpyted = Buffer.concat([decipher.update(Buffer.from(hash.content, 'hex')), decipher.final()]); const decrypted = Buffer.concat([decipher.update(Buffer.from(hash.content, 'hex')), decipher.final()]);
return decrpyted.toString(); return decrypted.toString();
}; };
module.exports = { module.exports = {

View File

@@ -1,20 +1,21 @@
const express = require('express'); const express = require('express');
const httpRoutes = require('../http-routes'); const httpRoutes = require('../http-routes');
const PORT = process.env.HTTP_PORT || 3000; const {PORT, HTTP_PORT_MAX} = require('../config');
const doListen = (logger, app, port, resolve) => { const doListen = (logger, app, port, resolve) => {
const server = app.listen(port, () => { const server = app.listen(port, () => {
const {srf} = app.locals; const {srf} = app.locals;
logger.info(`listening for HTTP requests on port ${PORT}, serviceUrl is ${srf.locals.serviceUrl}`); srf.locals.serviceUrl = `http://${srf.locals.ipv4}:${port}`;
logger.info(`listening for HTTP requests on port ${port}, serviceUrl is ${srf.locals.serviceUrl}`);
resolve({server, app}); resolve({server, app});
}); });
return server; return server;
}; };
const handleErrors = (logger, app, resolve, reject, e) => { const handleErrors = (logger, app, resolve, reject, e) => {
if (e.code === 'EADDRINUSE' && if (e.code === 'EADDRINUSE' &&
process.env.HTTP_PORT_MAX && HTTP_PORT_MAX &&
e.port < process.env.HTTP_PORT_MAX) { e.port < HTTP_PORT_MAX) {
logger.info(`HTTP server failed to bind port on ${e.port}, will try next port`); logger.info(`HTTP server failed to bind port on ${e.port}, will try next port`);
const server = doListen(logger, app, ++e.port, resolve); const server = doListen(logger, app, ++e.port, resolve);

View File

@@ -5,7 +5,12 @@ const BaseRequestor = require('./base-requestor');
const {HookMsgTypes} = require('./constants.json'); const {HookMsgTypes} = require('./constants.json');
const snakeCaseKeys = require('./snakecase-keys'); const snakeCaseKeys = require('./snakecase-keys');
const pools = new Map(); const pools = new Map();
const HTTP_TIMEOUT = 10000; const {
HTTP_POOL,
HTTP_POOLSIZE,
HTTP_PIPELINING,
HTTP_TIMEOUT,
} = require('../config');
const toBase64 = (str) => Buffer.from(str || '', 'utf8').toString('base64'); const toBase64 = (str) => Buffer.from(str || '', 'utf8').toString('base64');
@@ -34,15 +39,15 @@ class HttpRequestor extends BaseRequestor {
this._resource = u.resource; this._resource = u.resource;
this._port = u.port; this._port = u.port;
this._search = u.search; this._search = u.search;
this._usePools = process.env.HTTP_POOL && parseInt(process.env.HTTP_POOL); this._usePools = HTTP_POOL && parseInt(HTTP_POOL);
if (this._usePools) { if (this._usePools) {
if (pools.has(this._baseUrl)) { if (pools.has(this._baseUrl)) {
this.client = pools.get(this._baseUrl); this.client = pools.get(this._baseUrl);
} }
else { else {
const connections = process.env.HTTP_POOLSIZE ? parseInt(process.env.HTTP_POOLSIZE) : 10; const connections = HTTP_POOLSIZE ? parseInt(HTTP_POOLSIZE) : 10;
const pipelining = process.env.HTTP_PIPELINING ? parseInt(process.env.HTTP_PIPELINING) : 1; const pipelining = HTTP_PIPELINING ? parseInt(HTTP_PIPELINING) : 1;
const pool = this.client = new Pool(this._baseUrl, { const pool = this.client = new Pool(this._baseUrl, {
connections, connections,
pipelining pipelining

View File

@@ -1,6 +1,22 @@
const Mrf = require('drachtio-fsmrf'); const Mrf = require('drachtio-fsmrf');
const ip = require('ip'); const ip = require('ip');
const PORT = process.env.HTTP_PORT || 3000; const {
JAMBONES_MYSQL_HOST,
JAMBONES_MYSQL_USER,
JAMBONES_MYSQL_PASSWORD,
JAMBONES_MYSQL_DATABASE,
JAMBONES_MYSQL_CONNECTION_LIMIT,
JAMBONES_MYSQL_PORT,
JAMBONES_FREESWITCH,
JAMBONES_REDIS_HOST,
JAMBONES_REDIS_PORT,
JAMBONES_REDIS_SENTINELS,
SMPP_URL,
JAMBONES_TIME_SERIES_HOST,
JAMBONES_ESL_LISTEN_ADDRESS,
PORT,
NODE_ENV,
} = require('../config');
const assert = require('assert'); const assert = require('assert');
function initMS(logger, wrapper, ms) { function initMS(logger, wrapper, ms) {
@@ -42,18 +58,18 @@ function installSrfLocals(srf, logger) {
let idxStart = 0; let idxStart = 0;
(async function() { (async function() {
const fsInventory = process.env.JAMBONES_FREESWITCH const fsInventory = JAMBONES_FREESWITCH
.split(',') .split(',')
.map((fs) => { .map((fs) => {
const arr = /^([^:]*):([^:]*):([^:]*)(?::([^:]*))?/.exec(fs); const arr = /^([^:]*):([^:]*):([^:]*)(?::([^:]*))?/.exec(fs);
assert.ok(arr, `Invalid syntax JAMBONES_FREESWITCH: ${process.env.JAMBONES_FREESWITCH}`); assert.ok(arr, `Invalid syntax JAMBONES_FREESWITCH: ${JAMBONES_FREESWITCH}`);
const opts = {address: arr[1], port: arr[2], secret: arr[3]}; const opts = {address: arr[1], port: arr[2], secret: arr[3]};
if (arr.length > 4) opts.advertisedAddress = arr[4]; if (arr.length > 4) opts.advertisedAddress = arr[4];
/* NB: originally for testing only, but for now all jambonz deployments /* NB: originally for testing only, but for now all jambonz deployments
have freeswitch installed locally alongside this app have freeswitch installed locally alongside this app
*/ */
if (process.env.NODE_ENV === 'test') opts.listenAddress = '0.0.0.0'; if (NODE_ENV === 'test') opts.listenAddress = '0.0.0.0';
else if (process.env.JAMBONES_ESL_LISTEN_ADDRESS) opts.listenAddress = process.env.JAMBONES_ESL_LISTEN_ADDRESS; else if (JAMBONES_ESL_LISTEN_ADDRESS) opts.listenAddress = JAMBONES_ESL_LISTEN_ADDRESS;
return opts; return opts;
}); });
logger.info({fsInventory}, 'freeswitch inventory'); logger.info({fsInventory}, 'freeswitch inventory');
@@ -125,20 +141,19 @@ function installSrfLocals(srf, logger) {
lookupAccountCapacitiesBySid, lookupAccountCapacitiesBySid,
lookupSmppGateways lookupSmppGateways
} = require('@jambonz/db-helpers')({ } = require('@jambonz/db-helpers')({
host: process.env.JAMBONES_MYSQL_HOST, host: JAMBONES_MYSQL_HOST,
user: process.env.JAMBONES_MYSQL_USER, user: JAMBONES_MYSQL_USER,
port: process.env.JAMBONES_MYSQL_PORT || 3306, port: JAMBONES_MYSQL_PORT || 3306,
password: process.env.JAMBONES_MYSQL_PASSWORD, password: JAMBONES_MYSQL_PASSWORD,
database: process.env.JAMBONES_MYSQL_DATABASE, database: JAMBONES_MYSQL_DATABASE,
connectionLimit: process.env.JAMBONES_MYSQL_CONNECTION_LIMIT || 10 connectionLimit: JAMBONES_MYSQL_CONNECTION_LIMIT || 10
}, logger, tracer); }, logger);
const { const {
client, client,
updateCallStatus, updateCallStatus,
retrieveCall, retrieveCall,
listCalls, listCalls,
deleteCall, deleteCall,
synthAudio,
createHash, createHash,
retrieveHash, retrieveHash,
deleteKey, deleteKey,
@@ -151,21 +166,32 @@ function installSrfLocals(srf, logger) {
pushBack, pushBack,
popFront, popFront,
removeFromList, removeFromList,
lengthOfList,
getListPosition, getListPosition,
lengthOfList,
addToSortedSet,
retrieveFromSortedSet,
retrieveByPatternSortedSet,
sortedSetLength,
sortedSetPositionByPattern
} = require('@jambonz/realtimedb-helpers')(JAMBONES_REDIS_SENTINELS || {
host: JAMBONES_REDIS_HOST,
port: JAMBONES_REDIS_PORT || 6379
}, logger, tracer);
const {
synthAudio,
getNuanceAccessToken, getNuanceAccessToken,
getIbmAccessToken, getIbmAccessToken,
} = require('@jambonz/realtimedb-helpers')({ } = require('@jambonz/speech-utils')(JAMBONES_REDIS_SENTINELS || {
host: process.env.JAMBONES_REDIS_HOST, host: JAMBONES_REDIS_HOST,
port: process.env.JAMBONES_REDIS_PORT || 6379 port: JAMBONES_REDIS_PORT || 6379
}, logger, tracer); }, logger, tracer);
const { const {
writeAlerts, writeAlerts,
AlertType AlertType
} = require('@jambonz/time-series')(logger, { } = require('@jambonz/time-series')(logger, {
host: process.env.JAMBONES_TIME_SERIES_HOST, host: JAMBONES_TIME_SERIES_HOST,
commitSize: 50, commitSize: 50,
commitInterval: 'test' === process.env.NODE_ENV ? 7 : 20 commitInterval: 'test' === NODE_ENV ? 7 : 20
}); });
let localIp; let localIp;
@@ -208,12 +234,17 @@ function installSrfLocals(srf, logger) {
lengthOfList, lengthOfList,
getListPosition, getListPosition,
getNuanceAccessToken, getNuanceAccessToken,
getIbmAccessToken getIbmAccessToken,
addToSortedSet,
retrieveFromSortedSet,
retrieveByPatternSortedSet,
sortedSetLength,
sortedSetPositionByPattern
}, },
parentLogger: logger, parentLogger: logger,
getSBC, getSBC,
getSmpp: () => { getSmpp: () => {
return process.env.SMPP_URL; return SMPP_URL;
}, },
lifecycleEmitter, lifecycleEmitter,
getFreeswitch, getFreeswitch,

View File

@@ -15,7 +15,7 @@ const RootSpan = require('./call-tracer');
const uuidv4 = require('uuid-random'); 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, dialTask}) {
super(); super();
assert(target.type); assert(target.type);
@@ -37,6 +37,7 @@ class SingleDialer extends Emitter {
this.callGone = false; this.callGone = false;
this.callSid = uuidv4(); this.callSid = uuidv4();
this.dialTask = dialTask;
this.on('callStatusChange', this._notifyCallStatusChange.bind(this)); this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
} }
@@ -45,6 +46,10 @@ class SingleDialer extends Emitter {
return this.callInfo.callStatus; return this.callInfo.callStatus;
} }
get applicationSid() {
return this.application?.application_sid || this.callInfo?.applicationSid;
}
/** /**
* can be used for all http requests within this session * can be used for all http requests within this session
*/ */
@@ -243,9 +248,14 @@ class SingleDialer extends Emitter {
.on('modify', async(req, res) => { .on('modify', async(req, res) => {
try { try {
if (this.ep) { if (this.ep) {
const newSdp = await this.ep.modify(req.body); if (this.dialTask && this.dialTask.isOnHold) {
res.send(200, {body: newSdp}); this.logger.info('dial is onhold, emit event');
this.logger.info({offer: req.body, answer: newSdp}, 'SingleDialer:exec: handling reINVITE'); this.emit('reinvite', req, res);
} else {
const newSdp = await this.ep.modify(req.body);
res.send(200, {body: newSdp});
this.logger.info({offer: req.body, answer: newSdp}, 'SingleDialer:exec: handling reINVITE');
}
} }
else { else {
this.logger.info('SingleDialer:exec: handling reINVITE with released media, emit event'); this.logger.info('SingleDialer:exec: handling reINVITE with released media, emit event');
@@ -426,11 +436,11 @@ class SingleDialer extends Emitter {
} }
function placeOutdial({ function placeOutdial({
logger, srf, ms, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan logger, srf, ms, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan, dialTask
}) { }) {
const myOpts = deepcopy(opts); const myOpts = deepcopy(opts);
const sd = new SingleDialer({ const sd = new SingleDialer({
logger, sbcAddress, target, myOpts, application, callInfo, accountInfo, rootSpan, startSpan logger, sbcAddress, target, myOpts, application, callInfo, accountInfo, rootSpan, startSpan, dialTask
}); });
sd.exec(srf, ms, myOpts); sd.exec(srf, ms, myOpts);
return sd; return sd;

View File

@@ -1,5 +1,9 @@
const assert = require('assert'); const assert = require('assert');
const timeSeries = require('@jambonz/time-series'); const timeSeries = require('@jambonz/time-series');
const {
NODE_ENV,
JAMBONES_TIME_SERIES_HOST
} = require('../config');
let alerter ; let alerter ;
function isAbsoluteUrl(u) { function isAbsoluteUrl(u) {
@@ -28,9 +32,9 @@ class Requestor {
if (!alerter) { if (!alerter) {
alerter = timeSeries(logger, { alerter = timeSeries(logger, {
host: process.env.JAMBONES_TIME_SERIES_HOST, host: JAMBONES_TIME_SERIES_HOST,
commitSize: 50, commitSize: 50,
commitInterval: 'test' === process.env.NODE_ENV ? 7 : 20 commitInterval: 'test' === NODE_ENV ? 7 : 20
}); });
} }
} }
@@ -38,9 +42,9 @@ class Requestor {
get Alerter() { get Alerter() {
if (!alerter) { if (!alerter) {
alerter = timeSeries(this.logger, { alerter = timeSeries(this.logger, {
host: process.env.JAMBONES_TIME_SERIES_HOST, host: JAMBONES_TIME_SERIES_HOST,
commitSize: 50, commitSize: 50,
commitInterval: 'test' === process.env.NODE_ENV ? 7 : 20 commitInterval: 'test' === NODE_ENV ? 7 : 20
}); });
} }
return alerter; return alerter;

View File

@@ -4,28 +4,38 @@ 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');
const noopLogger = {info: () => {}, error: () => {}}; const noopLogger = {info: () => {}, error: () => {}};
const {
JAMBONES_SBCS,
K8S,
K8S_SBC_SIP_SERVICE_NAME,
AWS_SNS_TOPIC_ARM,
OPTIONS_PING_INTERVAL,
AWS_REGION,
NODE_ENV,
JAMBONES_CLUSTER_ID,
} = require('../config');
module.exports = (logger) => { module.exports = (logger) => {
logger = logger || noopLogger; logger = logger || noopLogger;
let idxSbc = 0; let idxSbc = 0;
let sbcs = []; let sbcs = [];
if (process.env.JAMBONES_SBCS) { if (JAMBONES_SBCS) {
sbcs = process.env.JAMBONES_SBCS sbcs = JAMBONES_SBCS
.split(',') .split(',')
.map((sbc) => sbc.trim()); .map((sbc) => sbc.trim());
assert.ok(sbcs.length, 'JAMBONES_SBCS env var is empty or misconfigured'); assert.ok(sbcs.length, 'JAMBONES_SBCS env var is empty or misconfigured');
logger.info({sbcs}, 'SBC inventory'); logger.info({sbcs}, 'SBC inventory');
} }
else if (process.env.K8S && process.env.K8S_SBC_SIP_SERVICE_NAME) { else if (K8S && K8S_SBC_SIP_SERVICE_NAME) {
sbcs = [`${process.env.K8S_SBC_SIP_SERVICE_NAME}:5060`]; sbcs = [`${K8S_SBC_SIP_SERVICE_NAME}:5060`];
logger.info({sbcs}, 'SBC inventory'); logger.info({sbcs}, 'SBC inventory');
} }
// listen for SNS lifecycle changes // listen for SNS lifecycle changes
let lifecycleEmitter = new Emitter(); let lifecycleEmitter = new Emitter();
let dryUpCalls = false; let dryUpCalls = false;
if (process.env.AWS_SNS_TOPIC_ARM && process.env.AWS_REGION) { if (AWS_SNS_TOPIC_ARM && AWS_REGION) {
(async function() { (async function() {
try { try {
@@ -75,13 +85,13 @@ module.exports = (logger) => {
} }
})(); })();
} }
else if (process.env.K8S) { else if (K8S) {
lifecycleEmitter.scaleIn = () => process.exit(0); lifecycleEmitter.scaleIn = () => process.exit(0);
} }
async function pingProxies(srf) { async function pingProxies(srf) {
if (process.env.NODE_ENV === 'test') return; if (NODE_ENV === 'test') return;
for (const sbc of sbcs) { for (const sbc of sbcs) {
try { try {
@@ -91,7 +101,8 @@ module.exports = (logger) => {
method: 'OPTIONS', method: 'OPTIONS',
headers: { headers: {
'X-FS-Status': ms && !dryUpCalls ? 'open' : 'closed', 'X-FS-Status': ms && !dryUpCalls ? 'open' : 'closed',
'X-FS-Calls': srf.locals.sessionTracker.count 'X-FS-Calls': srf.locals.sessionTracker.count,
'X-FS-ServiceUrl': srf.locals.serviceUrl
} }
}); });
req.on('response', (res) => { req.on('response', (res) => {
@@ -102,7 +113,7 @@ module.exports = (logger) => {
} }
} }
} }
if (process.env.K8S) { if (K8S) {
setImmediate(() => { setImmediate(() => {
logger.info('disabling OPTIONS pings since we are running as a kubernetes service'); logger.info('disabling OPTIONS pings since we are running as a kubernetes service');
const {srf} = require('../..'); const {srf} = require('../..');
@@ -123,16 +134,16 @@ module.exports = (logger) => {
setInterval(() => { setInterval(() => {
const {srf} = require('../..'); const {srf} = require('../..');
pingProxies(srf); pingProxies(srf);
}, process.env.OPTIONS_PING_INTERVAL || 30000); }, OPTIONS_PING_INTERVAL);
// initial ping once we are up // initial ping once we are up
setTimeout(async() => { setTimeout(async() => {
// if SBCs are auto-scaling, monitor them as they come and go // if SBCs are auto-scaling, monitor them as they come and go
const {srf} = require('../..'); const {srf} = require('../..');
if (!process.env.JAMBONES_SBCS) { if (!JAMBONES_SBCS) {
const {monitorSet} = srf.locals.dbHelpers; const {monitorSet} = srf.locals.dbHelpers;
const setName = `${(process.env.JAMBONES_CLUSTER_ID || 'default')}:active-sip`; const setName = `${(JAMBONES_CLUSTER_ID || 'default')}:active-sip`;
await monitorSet(setName, 10, (members) => { await monitorSet(setName, 10, (members) => {
sbcs = members; sbcs = members;
logger.info(`sbc-pinger: SBC roster has changed, list of active SBCs is now ${sbcs}`); logger.info(`sbc-pinger: SBC roster has changed, list of active SBCs is now ${sbcs}`);

32
lib/utils/sdp-utils.js Normal file
View File

@@ -0,0 +1,32 @@
const sdpTransform = require('sdp-transform');
const isOnhold = (sdp) => {
return sdp && (sdp.includes('a=sendonly') || sdp.includes('a=inactive'));
};
const mergeSdpMedia = (sdp1, sdp2) => {
const parsedSdp1 = sdpTransform.parse(sdp1);
const parsedSdp2 = sdpTransform.parse(sdp2);
parsedSdp1.media.push(...parsedSdp2.media);
return sdpTransform.write(parsedSdp1);
};
const extractSdpMedia = (sdp) => {
const parsedSdp1 = sdpTransform.parse(sdp);
if (parsedSdp1.media.length > 1) {
parsedSdp1.media = [parsedSdp1.media[0]];
const parsedSdp2 = sdpTransform.parse(sdp);
parsedSdp2.media = [parsedSdp2.media[1]];
return [sdpTransform.write(parsedSdp1), sdpTransform.write(parsedSdp2)];
} else {
return [sdp, sdp];
}
};
module.exports = {
isOnhold,
mergeSdpMedia,
extractSdpMedia
};

View File

@@ -47,8 +47,16 @@ const parseSiprecPayload = (req, logger) => {
} }
} }
if (!sdp || !meta) {
logger.info({payload: req.payload}, 'invalid SIPREC payload'); if (!meta && sdp) {
const arr = /^([^]+)(m=[^]+?)(m=[^]+?)$/.exec(sdp);
opts.sdp1 = `${arr[1]}${arr[2]}`;
opts.sdp2 = `${arr[1]}${arr[3]}\r\n`;
opts.sessionId = uuidv4();
logger.info({ payload: req.payload }, 'SIPREC payload with no metadata (e.g. Cisco NBR)');
resolve(opts);
} else if (!sdp || !meta) {
logger.info({ payload: req.payload }, 'invalid SIPREC payload');
return reject(new Error('expected multipart SIPREC body')); return reject(new Error('expected multipart SIPREC body'));
} }
@@ -242,7 +250,8 @@ const createSipRecPayload = (sdp1, sdp2, logger) => {
.replace(/a=sendonly\r\n/g, '') .replace(/a=sendonly\r\n/g, '')
.replace(/a=direction:both\r\n/g, ''); .replace(/a=direction:both\r\n/g, '');
*/ */
return combinedSdp;
return combinedSdp.replace(/sendrecv/g, 'recvonly');
}; };
module.exports = { parseSiprecPayload, createSipRecPayload } ; module.exports = { parseSiprecPayload, createSipRecPayload } ;

View File

@@ -5,7 +5,10 @@ const {
AwsTranscriptionEvents, AwsTranscriptionEvents,
NuanceTranscriptionEvents, NuanceTranscriptionEvents,
DeepgramTranscriptionEvents, DeepgramTranscriptionEvents,
NvidiaTranscriptionEvents SonioxTranscriptionEvents,
NvidiaTranscriptionEvents,
CobaltTranscriptionEvents,
JambonzTranscriptionEvents
} = require('./constants'); } = require('./constants');
const stickyVars = { const stickyVars = {
@@ -27,6 +30,7 @@ const stickyVars = {
'AZURE_SERVICE_ENDPOINT_ID', 'AZURE_SERVICE_ENDPOINT_ID',
'AZURE_REQUEST_SNR', 'AZURE_REQUEST_SNR',
'AZURE_PROFANITY_OPTION', 'AZURE_PROFANITY_OPTION',
'AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES',
'AZURE_SERVICE_ENDPOINT', 'AZURE_SERVICE_ENDPOINT',
'AZURE_INITIAL_SPEECH_TIMEOUT_MS', 'AZURE_INITIAL_SPEECH_TIMEOUT_MS',
'AZURE_USE_OUTPUT_FORMAT_DETAILED', 'AZURE_USE_OUTPUT_FORMAT_DETAILED',
@@ -88,9 +92,75 @@ const stickyVars = {
], ],
nvidia: [ nvidia: [
'NVIDIA_HINTS' 'NVIDIA_HINTS'
],
cobalt: [
'COBALT_SPEECH_HINTS',
'COBALT_COMPILED_CONTEXT_DATA',
'COBALT_METADATA'
],
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 normalizeDeepgram = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt)); const copy = JSON.parse(JSON.stringify(evt));
const alternatives = (evt.channel?.alternatives || []) const alternatives = (evt.channel?.alternatives || [])
@@ -161,6 +231,40 @@ const normalizeGoogle = (evt, channel, language) => {
}; };
}; };
const normalizeCobalt = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
const alternatives = (evt.alternatives || [])
.map((alt) => ({
confidence: alt.confidence,
transcript: alt.transcript_formatted,
}));
return {
language_code: language,
channel_tag: channel,
is_final: evt.is_final,
alternatives,
vendor: {
name: 'cobalt',
evt: copy
}
};
};
const normalizeCustom = (evt, channel, language, vendor) => {
const copy = JSON.parse(JSON.stringify(evt));
return {
language_code: language,
channel_tag: channel,
is_final: evt.is_final,
alternatives: [evt.alternatives[0]],
vendor: {
name: vendor,
evt: copy
}
};
};
const normalizeNuance = (evt, channel, language) => { const normalizeNuance = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt)); const copy = JSON.parse(JSON.stringify(evt));
return { return {
@@ -221,7 +325,7 @@ const normalizeAws = (evt, channel, language) => {
module.exports = (logger) => { module.exports = (logger) => {
const normalizeTranscription = (evt, vendor, channel, language) => { const normalizeTranscription = (evt, vendor, channel, language) => {
logger.debug({ evt, vendor, channel, language }, 'normalizeTranscription'); //logger.debug({ evt, vendor, channel, language }, 'normalizeTranscription');
switch (vendor) { switch (vendor) {
case 'deepgram': case 'deepgram':
return normalizeDeepgram(evt, channel, language); return normalizeDeepgram(evt, channel, language);
@@ -237,7 +341,14 @@ module.exports = (logger) => {
return normalizeIbm(evt, channel, language); return normalizeIbm(evt, channel, language);
case 'nvidia': case 'nvidia':
return normalizeNvidia(evt, channel, language); return normalizeNvidia(evt, channel, language);
case 'soniox':
return normalizeSoniox(evt, channel, language);
case 'cobalt':
return normalizeCobalt(evt, channel, language);
default: default:
if (vendor.startsWith('custom:')) {
return normalizeCustom(evt, channel, language, vendor);
}
logger.error(`Unknown vendor ${vendor}`); logger.error(`Unknown vendor ${vendor}`);
return evt; return evt;
} }
@@ -247,6 +358,7 @@ module.exports = (logger) => {
let opts = {}; let opts = {};
const {enable, voiceMs = 0, mode = -1} = rOpts.vad || {}; const {enable, voiceMs = 0, mode = -1} = rOpts.vad || {};
const vad = {enable, voiceMs, mode}; const vad = {enable, voiceMs, mode};
const vendor = rOpts.vendor;
/* voice activity detection works across vendors */ /* voice activity detection works across vendors */
opts = { opts = {
@@ -256,59 +368,42 @@ module.exports = (logger) => {
...(vad.enable && typeof vad.mode === 'number' && {RECOGNIZER_VAD_MODE: vad.mode}), ...(vad.enable && typeof vad.mode === 'number' && {RECOGNIZER_VAD_MODE: vad.mode}),
}; };
if ('google' === rOpts.vendor) { if ('google' === vendor) {
const model = task.name === TaskName.Gather ? 'command_and_search' : 'latest_long';
opts = { opts = {
...opts, ...opts,
...(sttCredentials && ...(sttCredentials && {GOOGLE_APPLICATION_CREDENTIALS: JSON.stringify(sttCredentials.credentials)}),
{GOOGLE_APPLICATION_CREDENTIALS: JSON.stringify(sttCredentials.credentials)}), ...(rOpts.separateRecognitionPerChannel && {GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL: 1}),
...(rOpts.enhancedModel && ...(rOpts.separateRecognitionPerChanne === false && {GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL: 0}),
{GOOGLE_SPEECH_USE_ENHANCED: 1}), ...(rOpts.profanityFilter && {GOOGLE_SPEECH_PROFANITY_FILTER: 1}),
...(rOpts.separateRecognitionPerChannel && ...(rOpts.punctuation && {GOOGLE_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION: 1}),
{GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL: 1}), ...(rOpts.words && {GOOGLE_SPEECH_ENABLE_WORD_TIME_OFFSETS: 1}),
...(rOpts.profanityFilter && ...(rOpts.singleUtterance && {GOOGLE_SPEECH_SINGLE_UTTERANCE: 1}),
{GOOGLE_SPEECH_PROFANITY_FILTER: 1}), ...(rOpts.diarization && {GOOGLE_SPEECH_SPEAKER_DIARIZATION: 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 && ...(rOpts.diarization && rOpts.diarizationMinSpeakers > 0 &&
{GOOGLE_SPEECH_SPEAKER_DIARIZATION_MIN_SPEAKER_COUNT: rOpts.diarizationMinSpeakers}), {GOOGLE_SPEECH_SPEAKER_DIARIZATION_MIN_SPEAKER_COUNT: rOpts.diarizationMinSpeakers}),
...(rOpts.diarization && rOpts.diarizationMaxSpeakers > 0 && ...(rOpts.diarization && rOpts.diarizationMaxSpeakers > 0 &&
{GOOGLE_SPEECH_SPEAKER_DIARIZATION_MAX_SPEAKER_COUNT: rOpts.diarizationMaxSpeakers}), {GOOGLE_SPEECH_SPEAKER_DIARIZATION_MAX_SPEAKER_COUNT: rOpts.diarizationMaxSpeakers}),
...(rOpts.enhancedModel === false && ...(rOpts.enhancedModel !== false && {GOOGLE_SPEECH_USE_ENHANCED: 1}),
{GOOGLE_SPEECH_USE_ENHANCED: 0}), ...(rOpts.profanityFilter === false && {GOOGLE_SPEECH_PROFANITY_FILTER: 0}),
...(rOpts.separateRecognitionPerChannel === false && ...(rOpts.punctuation === false && {GOOGLE_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION: 0}),
{GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL: 0}), ...(rOpts.words == false && {GOOGLE_SPEECH_ENABLE_WORD_TIME_OFFSETS: 0}),
...(rOpts.profanityFilter === false && ...(rOpts.diarization === false && {GOOGLE_SPEECH_SPEAKER_DIARIZATION: 0}),
{GOOGLE_SPEECH_PROFANITY_FILTER: 0}), ...(rOpts.hints?.length > 0 && typeof rOpts.hints[0] === 'string' &&
...(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(',')}), {GOOGLE_SPEECH_HINTS: rOpts.hints.join(',')}),
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'object' && ...(rOpts.hints?.length > 0 && typeof rOpts.hints[0] === 'object' &&
{GOOGLE_SPEECH_HINTS: JSON.stringify(rOpts.hints)}), {GOOGLE_SPEECH_HINTS: JSON.stringify(rOpts.hints)}),
...(typeof rOpts.hintsBoost === 'number' && ...(typeof rOpts.hintsBoost === 'number' && {GOOGLE_SPEECH_HINTS_BOOST: rOpts.hintsBoost}),
{GOOGLE_SPEECH_HINTS_BOOST: rOpts.hintsBoost}), ...(rOpts.altLanguages?.length > 0 &&
...(rOpts.altLanguages.length > 0 && {GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES: [...new Set(rOpts.altLanguages)].join(',')}),
{GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES: rOpts.altLanguages.join(',')}),
...(rOpts.interactionType && ...(rOpts.interactionType &&
{GOOGLE_SPEECH_METADATA_INTERACTION_TYPE: rOpts.interactionType}), {GOOGLE_SPEECH_METADATA_INTERACTION_TYPE: rOpts.interactionType}),
...{GOOGLE_SPEECH_MODEL: rOpts.model || (task.name === TaskName.Gather ? 'latest_short' : 'phone_call')}, ...{GOOGLE_SPEECH_MODEL: rOpts.model || model},
...(rOpts.naicsCode > 0 && ...(rOpts.naicsCode > 0 && {GOOGLE_SPEECH_METADATA_INDUSTRY_NAICS_CODE: rOpts.naicsCode}),
{GOOGLE_SPEECH_METADATA_INDUSTRY_NAICS_CODE: rOpts.naicsCode}), GOOGLE_SPEECH_METADATA_RECORDING_DEVICE_TYPE: 'phone_line',
}; };
} }
else if (['aws', 'polly'].includes(rOpts.vendor)) { else if (['aws', 'polly'].includes(vendor)) {
opts = { opts = {
...opts, ...opts,
...(rOpts.vocabularyName && {AWS_VOCABULARY_NAME: rOpts.vocabularyName}), ...(rOpts.vocabularyName && {AWS_VOCABULARY_NAME: rOpts.vocabularyName}),
@@ -321,17 +416,19 @@ module.exports = (logger) => {
}), }),
}; };
} }
else if ('microsoft' === rOpts.vendor) { else if ('microsoft' === vendor) {
opts = { opts = {
...opts, ...opts,
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'string' && ...(rOpts.hints && rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'string' &&
{AZURE_SPEECH_HINTS: rOpts.hints.map((h) => h.trim()).join(',')}), {AZURE_SPEECH_HINTS: rOpts.hints.map((h) => h.trim()).join(',')}),
...(rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'object' && ...(rOpts.hints && rOpts.hints.length > 0 && typeof rOpts.hints[0] === 'object' &&
{AZURE_SPEECH_HINTS: rOpts.hints.map((h) => h.phrase).join(',')}), {AZURE_SPEECH_HINTS: rOpts.hints.map((h) => h.phrase).join(',')}),
...(rOpts.altLanguages && rOpts.altLanguages.length > 0 && ...(rOpts.altLanguages && rOpts.altLanguages.length > 0 &&
{AZURE_SERVICE_ENDPOINT_ID: rOpts.sttCredentials}), {AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES: [...new Set(rOpts.altLanguages)].join(',')}),
...(rOpts.requestSnr && {AZURE_REQUEST_SNR: 1}), ...(rOpts.requestSnr && {AZURE_REQUEST_SNR: 1}),
...(rOpts.profanityOption && {AZURE_PROFANITY_OPTION: rOpts.profanityOption}), ...(rOpts.profanityOption && {AZURE_PROFANITY_OPTION: rOpts.profanityOption}),
...(sttCredentials.use_custom_stt && sttCredentials.custom_stt_endpoint_url &&
{AZURE_SERVICE_ENDPOINT: sttCredentials.custom_stt_endpoint_url}),
...(rOpts.azureServiceEndpoint && {AZURE_SERVICE_ENDPOINT: rOpts.azureServiceEndpoint}), ...(rOpts.azureServiceEndpoint && {AZURE_SERVICE_ENDPOINT: rOpts.azureServiceEndpoint}),
...(rOpts.initialSpeechTimeoutMs > 0 && ...(rOpts.initialSpeechTimeoutMs > 0 &&
{AZURE_INITIAL_SPEECH_TIMEOUT_MS: rOpts.initialSpeechTimeoutMs}), {AZURE_INITIAL_SPEECH_TIMEOUT_MS: rOpts.initialSpeechTimeoutMs}),
@@ -339,14 +436,14 @@ module.exports = (logger) => {
...(rOpts.audioLogging && {AZURE_AUDIO_LOGGING: 1}), ...(rOpts.audioLogging && {AZURE_AUDIO_LOGGING: 1}),
...{AZURE_USE_OUTPUT_FORMAT_DETAILED: 1}, ...{AZURE_USE_OUTPUT_FORMAT_DETAILED: 1},
...(sttCredentials && { ...(sttCredentials && {
AZURE_SUBSCRIPTION_KEY: sttCredentials.api_key, ...(sttCredentials.api_key && {AZURE_SUBSCRIPTION_KEY: sttCredentials.api_key}),
AZURE_REGION: sttCredentials.region, ...(sttCredentials.region && {AZURE_REGION: sttCredentials.region}),
}), }),
...(sttCredentials.use_custom_stt && sttCredentials.custom_stt_endpoint && ...(sttCredentials.use_custom_stt && sttCredentials.custom_stt_endpoint &&
{AZURE_SERVICE_ENDPOINT_ID: sttCredentials.custom_stt_endpoint}) {AZURE_SERVICE_ENDPOINT_ID: sttCredentials.custom_stt_endpoint}),
}; };
} }
else if ('nuance' === rOpts.vendor) { else if ('nuance' === vendor) {
/** /**
* Note: all nuance options are in recognizer.nuanceOptions, should migrate * Note: all nuance options are in recognizer.nuanceOptions, should migrate
* other vendor settings to similar nested structure * other vendor settings to similar nested structure
@@ -354,12 +451,9 @@ module.exports = (logger) => {
const {nuanceOptions = {}} = rOpts; const {nuanceOptions = {}} = rOpts;
opts = { opts = {
...opts, ...opts,
...(sttCredentials.access_token) && ...(sttCredentials.access_token) && {NUANCE_ACCESS_TOKEN: sttCredentials.access_token},
{NUANCE_ACCESS_TOKEN: sttCredentials.access_token}, ...(sttCredentials.nuance_stt_uri) && {NUANCE_KRYPTON_ENDPOINT: sttCredentials.nuance_stt_uri},
...(sttCredentials.krypton_endpoint) && ...(nuanceOptions.topic) && {NUANCE_TOPIC: nuanceOptions.topic},
{NUANCE_KRYPTON_ENDPOINT: sttCredentials.krypton_endpoint},
...(nuanceOptions.topic) &&
{NUANCE_TOPIC: nuanceOptions.topic},
...(nuanceOptions.utteranceDetectionMode) && ...(nuanceOptions.utteranceDetectionMode) &&
{NUANCE_UTTERANCE_DETECTION_MODE: nuanceOptions.utteranceDetectionMode}, {NUANCE_UTTERANCE_DETECTION_MODE: nuanceOptions.utteranceDetectionMode},
...(nuanceOptions.punctuation || rOpts.punctuation) && {NUANCE_PUNCTUATION: nuanceOptions.punctuation}, ...(nuanceOptions.punctuation || rOpts.punctuation) && {NUANCE_PUNCTUATION: nuanceOptions.punctuation},
@@ -397,7 +491,7 @@ module.exports = (logger) => {
{NUANCE_RESOURCES: JSON.stringify(nuanceOptions.resources)}, {NUANCE_RESOURCES: JSON.stringify(nuanceOptions.resources)},
}; };
} }
else if ('deepgram' === rOpts.vendor) { else if ('deepgram' === vendor) {
const {deepgramOptions = {}} = rOpts; const {deepgramOptions = {}} = rOpts;
opts = { opts = {
...opts, ...opts,
@@ -412,7 +506,7 @@ module.exports = (logger) => {
...(deepgramOptions.profanityFilter) && ...(deepgramOptions.profanityFilter) &&
{DEEPGRAM_SPEECH_PROFANITY_FILTER: 1}, {DEEPGRAM_SPEECH_PROFANITY_FILTER: 1},
...(deepgramOptions.redact) && ...(deepgramOptions.redact) &&
{DEEPGRAM_SPEECH_REDACT: 1}, {DEEPGRAM_SPEECH_REDACT: deepgramOptions.redact},
...(deepgramOptions.diarize) && ...(deepgramOptions.diarize) &&
{DEEPGRAM_SPEECH_DIARIZE: 1}, {DEEPGRAM_SPEECH_DIARIZE: 1},
...(deepgramOptions.diarizeVersion) && ...(deepgramOptions.diarizeVersion) &&
@@ -441,7 +535,30 @@ module.exports = (logger) => {
{DEEPGRAM_SPEECH_TAG: deepgramOptions.tag} {DEEPGRAM_SPEECH_TAG: deepgramOptions.tag}
}; };
} }
else if ('ibm' === rOpts.vendor) { 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; const {ibmOptions = {}} = rOpts;
opts = { opts = {
...opts, ...opts,
@@ -465,8 +582,9 @@ module.exports = (logger) => {
{IBM_SPEECH_WATSON_LEARNING_OPT_OUT: ibmOptions.watsonLearningOptOut} {IBM_SPEECH_WATSON_LEARNING_OPT_OUT: ibmOptions.watsonLearningOptOut}
}; };
} }
else if ('nvidia' === rOpts.vendor) { else if ('nvidia' === vendor) {
const {nvidiaOptions = {}} = rOpts; const {nvidiaOptions = {}} = rOpts;
const rivaUri = nvidiaOptions.rivaUri || sttCredentials.riva_server_uri;
opts = { opts = {
...opts, ...opts,
...((nvidiaOptions.profanityFilter || rOpts.profanityFilter) && {NVIDIA_PROFANITY_FILTER: 1}), ...((nvidiaOptions.profanityFilter || rOpts.profanityFilter) && {NVIDIA_PROFANITY_FILTER: 1}),
@@ -478,7 +596,7 @@ module.exports = (logger) => {
...(nvidiaOptions.maxAlternatives && {NVIDIA_MAX_ALTERNATIVES: nvidiaOptions.maxAlternatives}), ...(nvidiaOptions.maxAlternatives && {NVIDIA_MAX_ALTERNATIVES: nvidiaOptions.maxAlternatives}),
...(!nvidiaOptions.maxAlternatives && {NVIDIA_MAX_ALTERNATIVES: 1}), ...(!nvidiaOptions.maxAlternatives && {NVIDIA_MAX_ALTERNATIVES: 1}),
...(rOpts.model && {NVIDIA_MODEL: rOpts.model}), ...(rOpts.model && {NVIDIA_MODEL: rOpts.model}),
...(nvidiaOptions.rivaUri && {NVIDIA_RIVA_URI: nvidiaOptions.rivaUri}), ...(rivaUri && {NVIDIA_RIVA_URI: rivaUri}),
...(nvidiaOptions.verbatimTranscripts && {NVIDIA_VERBATIM_TRANSCRIPTS: 1}), ...(nvidiaOptions.verbatimTranscripts && {NVIDIA_VERBATIM_TRANSCRIPTS: 1}),
...(rOpts.diarization && {NVIDIA_SPEAKER_DIARIZATION: 1}), ...(rOpts.diarization && {NVIDIA_SPEAKER_DIARIZATION: 1}),
...(rOpts.diarization && rOpts.diarizationMaxSpeakers > 0 && ...(rOpts.diarization && rOpts.diarizationMaxSpeakers > 0 &&
@@ -494,11 +612,48 @@ module.exports = (logger) => {
{NVIDIA_CUSTOM_CONFIGURATION: JSON.stringify(nvidiaOptions.customConfiguration)}), {NVIDIA_CUSTOM_CONFIGURATION: JSON.stringify(nvidiaOptions.customConfiguration)}),
}; };
} }
else if ('cobalt' === vendor) {
const {cobaltOptions = {}} = rOpts;
const cobaltUri = cobaltOptions.serverUri || sttCredentials.cobalt_server_uri;
opts = {
...opts,
...(rOpts.words && {COBALT_WORD_TIME_OFFSETS: 1}),
...(!rOpts.words && {COBALT_WORD_TIME_OFFSETS: 0}),
...(rOpts.model && {COBALT_MODEL: rOpts.model}),
...(cobaltUri && {COBALT_SERVER_URI: cobaltUri}),
...(rOpts.hints?.length > 0 && typeof rOpts.hints[0] === 'string' &&
{COBALT_SPEECH_HINTS: rOpts.hints.join(',')}),
...(rOpts.hints?.length > 0 && typeof rOpts.hints[0] === 'object' &&
{COBALT_SPEECH_HINTS: JSON.stringify(rOpts.hints)}),
...(rOpts.hints?.length > 0 && {COBALT_CONTEXT_TOKEN: cobaltOptions.contextToken || 'unk:default'}),
...(cobaltOptions.metadata && {COBALT_METADATA: cobaltOptions.metadata}),
...(cobaltOptions.enableConfusionNetwork && {COBALT_ENABLE_CONFUSION_NETWORK: 1}),
...(cobaltOptions.compiledContextData && {COBALT_COMPILED_CONTEXT_DATA: cobaltOptions.compiledContextData}),
};
}
else if (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})
};
stickyVars[rOpts.vendor].forEach((key) => { 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] = ''; if (!opts[key]) opts[key] = '';
}); });
logger.debug({opts}, 'recognizer channel vars');
return opts; return opts;
}; };
@@ -517,25 +672,35 @@ module.exports = (logger) => {
ep.removeCustomEventListener(NuanceTranscriptionEvents.Transcription); ep.removeCustomEventListener(NuanceTranscriptionEvents.Transcription);
ep.removeCustomEventListener(NuanceTranscriptionEvents.TranscriptionComplete); ep.removeCustomEventListener(NuanceTranscriptionEvents.TranscriptionComplete);
ep.removeCustomEventListener(NuanceTranscriptionEvents.StartOfSpeech); ep.removeCustomEventListener(NuanceTranscriptionEvents.StartOfSpeech);
ep.removeCustomEventListener(NuanceTranscriptionEvents.Error);
ep.removeCustomEventListener(NuanceTranscriptionEvents.VadDetected); ep.removeCustomEventListener(NuanceTranscriptionEvents.VadDetected);
ep.removeCustomEventListener(DeepgramTranscriptionEvents.Transcription); ep.removeCustomEventListener(DeepgramTranscriptionEvents.Transcription);
ep.removeCustomEventListener(DeepgramTranscriptionEvents.Connect); ep.removeCustomEventListener(DeepgramTranscriptionEvents.Connect);
ep.removeCustomEventListener(DeepgramTranscriptionEvents.ConnectFailure); ep.removeCustomEventListener(DeepgramTranscriptionEvents.ConnectFailure);
ep.removeCustomEventListener(SonioxTranscriptionEvents.Transcription);
ep.removeCustomEventListener(CobaltTranscriptionEvents.Transcription);
ep.removeCustomEventListener(CobaltTranscriptionEvents.CompileContext);
ep.removeCustomEventListener(NvidiaTranscriptionEvents.Transcription); ep.removeCustomEventListener(NvidiaTranscriptionEvents.Transcription);
ep.removeCustomEventListener(NvidiaTranscriptionEvents.TranscriptionComplete); ep.removeCustomEventListener(NvidiaTranscriptionEvents.TranscriptionComplete);
ep.removeCustomEventListener(NvidiaTranscriptionEvents.StartOfSpeech); ep.removeCustomEventListener(NvidiaTranscriptionEvents.StartOfSpeech);
ep.removeCustomEventListener(NvidiaTranscriptionEvents.Error);
ep.removeCustomEventListener(NvidiaTranscriptionEvents.VadDetected); ep.removeCustomEventListener(NvidiaTranscriptionEvents.VadDetected);
ep.removeCustomEventListener(JambonzTranscriptionEvents.Transcription);
ep.removeCustomEventListener(JambonzTranscriptionEvents.Connect);
ep.removeCustomEventListener(JambonzTranscriptionEvents.ConnectFailure);
ep.removeCustomEventListener(JambonzTranscriptionEvents.Error);
}; };
const setSpeechCredentialsAtRuntime = (recognizer) => { const setSpeechCredentialsAtRuntime = (recognizer) => {
if (!recognizer) return; if (!recognizer) return;
if (recognizer.vendor === 'nuance') { if (recognizer.vendor === 'nuance') {
const {clientId, secret} = recognizer.nuanceOptions || {}; const {clientId, secret, kryptonEndpoint} = recognizer.nuanceOptions || {};
if (clientId && secret) return {client_id: clientId, secret}; if (clientId && secret) return {client_id: clientId, secret};
if (kryptonEndpoint) return {nuance_stt_uri: kryptonEndpoint};
} }
else if (recognizer.vendor === 'nvidia') { else if (recognizer.vendor === 'nvidia') {
const {rivaUri} = recognizer.nvidiaOptions || {}; const {rivaUri} = recognizer.nvidiaOptions || {};
@@ -545,6 +710,14 @@ module.exports = (logger) => {
const {apiKey} = recognizer.deepgramOptions || {}; const {apiKey} = recognizer.deepgramOptions || {};
if (apiKey) return {api_key: apiKey}; if (apiKey) return {api_key: apiKey};
} }
else if (recognizer.vendor === 'soniox') {
const {apiKey} = recognizer.sonioxOptions || {};
if (apiKey) return {api_key: apiKey};
}
else if (recognizer.vendor === 'cobalt') {
const {serverUri} = recognizer.cobaltOptions || {};
if (serverUri) return {cobalt_server_uri: serverUri};
}
else if (recognizer.vendor === 'ibm') { else if (recognizer.vendor === 'ibm') {
const {ttsApiKey, ttsRegion, sttApiKey, sttRegion, instanceId} = recognizer.ibmOptions || {}; const {ttsApiKey, ttsRegion, sttApiKey, sttRegion, instanceId} = recognizer.ibmOptions || {};
if (ttsApiKey || sttApiKey) return { if (ttsApiKey || sttApiKey) return {
@@ -561,6 +734,7 @@ module.exports = (logger) => {
normalizeTranscription, normalizeTranscription,
setChannelVarsForStt, setChannelVarsForStt,
removeSpeechListeners, removeSpeechListeners,
setSpeechCredentialsAtRuntime setSpeechCredentialsAtRuntime,
compileSonioxTranscripts
}; };
}; };

View File

@@ -4,8 +4,13 @@ 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 MAX_RECONNECTS = 5; const {
const RESPONSE_TIMEOUT_MS = process.env.JAMBONES_WS_API_MSG_RESPONSE_TIMEOUT || 5000; RESPONSE_TIMEOUT_MS,
JAMBONES_WS_PING_INTERVAL_MS,
MAX_RECONNECTS,
JAMBONES_WS_HANDSHAKE_TIMEOUT_MS,
JAMBONES_WS_MAX_PAYLOAD
} = require('../config');
class WsRequestor extends BaseRequestor { class WsRequestor extends BaseRequestor {
constructor(logger, account_sid, hook, secret) { constructor(logger, account_sid, hook, secret) {
@@ -38,6 +43,7 @@ class WsRequestor extends BaseRequestor {
async request(type, hook, params, httpHeaders = {}) { async request(type, hook, params, httpHeaders = {}) {
assert(HookMsgTypes.includes(type)); assert(HookMsgTypes.includes(type));
const url = hook.url || hook; const url = hook.url || hook;
const wantsAck = !['call:status', 'verb:status', 'jambonz:error'].includes(type);
if (this.maliciousClient) { if (this.maliciousClient) {
this.logger.info({url: this.url}, 'WsRequestor:request - discarding msg to malicious client'); this.logger.info({url: this.url}, 'WsRequestor:request - discarding msg to malicious client');
@@ -68,11 +74,19 @@ class WsRequestor extends BaseRequestor {
if (this.connectInProgress) { if (this.connectInProgress) {
this.logger.debug( this.logger.debug(
`WsRequestor:request(${this.id}) - queueing ${type} message since we are connecting`); `WsRequestor:request(${this.id}) - queueing ${type} message since we are connecting`);
this.queuedMsg.push({type, hook, params, httpHeaders}); if (wantsAck) {
const p = new Promise((resolve, reject) => {
this.queuedMsg.push({type, hook, params, httpHeaders, promise: {resolve, reject}});
});
return p;
}
else {
this.queuedMsg.push({type, hook, params, httpHeaders});
}
return; return;
} }
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 for ${type}`);
if (this.connections >= MAX_RECONNECTS) { if (this.connections >= MAX_RECONNECTS) {
return Promise.reject(`max attempts connecting to ${this.url}`); return Promise.reject(`max attempts connecting to ${this.url}`);
} }
@@ -111,9 +125,14 @@ class WsRequestor extends BaseRequestor {
const sendQueuedMsgs = () => { const sendQueuedMsgs = () => {
if (this.queuedMsg.length > 0) { if (this.queuedMsg.length > 0) {
for (const {type, hook, params, httpHeaders} of this.queuedMsg) { for (const {type, hook, params, httpHeaders, promise} of this.queuedMsg) {
this.logger.debug(`WsRequestor:request - preparing queued ${type} for sending`); this.logger.debug(`WsRequestor:request - preparing queued ${type} for sending`);
setImmediate(this.request.bind(this, type, hook, params, httpHeaders)); if (promise) {
this.request(type, hook, params, httpHeaders)
.then((res) => promise.resolve(res))
.catch((err) => promise.reject(err));
}
else setImmediate(this.request.bind(this, type, hook, params, httpHeaders));
} }
this.queuedMsg.length = 0; this.queuedMsg.length = 0;
} }
@@ -132,8 +151,8 @@ class WsRequestor extends BaseRequestor {
} }
/* simple notifications */ /* simple notifications */
if (['call:status', 'verb:status', 'jambonz:error'].includes(type) || reconnectingWithoutAck) { if (!wantsAck || 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();
}); });
@@ -174,9 +193,17 @@ class WsRequestor extends BaseRequestor {
}); });
} }
_stopPingTimer() {
if (this._pingTimer) {
clearInterval(this._pingTimer);
this._pingTimer = null;
}
}
close() { close() {
this.closedGracefully = true; this.closedGracefully = true;
this.logger.debug('WsRequestor:close closing socket'); this.logger.debug('WsRequestor:close closing socket');
this._stopPingTimer();
try { try {
if (this.ws) { if (this.ws) {
this.ws.close(1000); this.ws.close(1000);
@@ -191,15 +218,16 @@ class WsRequestor extends BaseRequestor {
_connect() { _connect() {
assert(!this.ws); assert(!this.ws);
this._stopPingTimer();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const handshakeTimeout = process.env.JAMBONES_WS_HANDSHAKE_TIMEOUT_MS ? const handshakeTimeout = JAMBONES_WS_HANDSHAKE_TIMEOUT_MS ?
parseInt(process.env.JAMBONES_WS_HANDSHAKE_TIMEOUT_MS) : parseInt(JAMBONES_WS_HANDSHAKE_TIMEOUT_MS) :
1500; 1500;
let opts = { let opts = {
followRedirects: true, followRedirects: true,
maxRedirects: 2, maxRedirects: 2,
handshakeTimeout, handshakeTimeout,
maxPayload: process.env.JAMBONES_WS_MAX_PAYLOAD ? parseInt(process.env.JAMBONES_WS_MAX_PAYLOAD) : 24 * 1024, maxPayload: JAMBONES_WS_MAX_PAYLOAD ? parseInt(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}`};
@@ -219,7 +247,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))
@@ -252,10 +279,15 @@ class WsRequestor extends BaseRequestor {
this.connectInProgress = false; this.connectInProgress = false;
this.connections++; this.connections++;
this.emit('ready', ws); this.emit('ready', ws);
if (JAMBONES_WS_PING_INTERVAL_MS > 15000) {
this._pingTimer = setInterval(() => this.ws?.ping(), JAMBONES_WS_PING_INTERVAL_MS);
}
} }
_onClose(code) { _onClose(code) {
this.logger.info(`WsRequestor(${this.id}) - closed from far end ${code}`); this.logger.info(`WsRequestor(${this.id}) - closed from far end ${code}`);
this._stopPingTimer();
if (this.connections > 0 && code !== 1000) { if (this.connections > 0 && code !== 1000) {
this.logger.info({url: this.url}, 'WsRequestor - socket closed unexpectedly from remote side'); this.logger.info({url: this.url}, 'WsRequestor - socket closed unexpectedly from remote side');
this.emit('socket-closed'); this.emit('socket-closed');
@@ -274,11 +306,13 @@ 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');
this._stopPingTimer();
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(); 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`);
@@ -338,7 +372,7 @@ class WsRequestor extends BaseRequestor {
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);

8952
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.8.0", "version": "0.8.4",
"main": "app.js", "main": "app.js",
"engines": { "engines": {
"node": ">= 10.16.0" "node": ">= 10.16.0"
@@ -19,17 +19,21 @@
"bugs": {}, "bugs": {},
"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:JambonzR0ck$:docker-host JAMBONES_TIME_SERIES_HOST=127.0.0.1 JAMBONES_NETWORK_CIDR=172.38.0.0/16 node test/ ", "test": "NODE_ENV=test JAMBONES_HOSTING=1 HTTP_POOL=1 JAMBONES_TTS_TRIM_SILENCE=1 ENCRYPTION_SECRET=foobar DRACHTIO_HOST=127.0.0.1 DRACHTIO_PORT=9060 DRACHTIO_SECRET=cymru JAMBONES_MYSQL_HOST=127.0.0.1 JAMBONES_MYSQL_PORT=3360 JAMBONES_MYSQL_USER=jambones_test JAMBONES_MYSQL_PASSWORD=jambones_test JAMBONES_MYSQL_DATABASE=jambones_test JAMBONES_REDIS_HOST=127.0.0.1 JAMBONES_REDIS_PORT=16379 JAMBONES_LOGLEVEL=error ENABLE_METRICS=0 HTTP_PORT=3000 JAMBONES_SBCS=172.38.0.10 JAMBONES_FREESWITCH=127.0.0.1:8022:JambonzR0ck$:docker-host JAMBONES_TIME_SERIES_HOST=127.0.0.1 JAMBONES_NETWORK_CIDR=172.38.0.0/16 node test/ ",
"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 tracer.js lib",
"jslint:fix": "eslint app.js tracer.js lib --fix"
}, },
"dependencies": { "dependencies": {
"@jambonz/db-helpers": "^0.7.4", "@aws-sdk/client-auto-scaling": "^3.360.0",
"@aws-sdk/client-sns": "^3.360.0",
"@jambonz/db-helpers": "^0.9.1",
"@jambonz/http-health-check": "^0.0.1", "@jambonz/http-health-check": "^0.0.1",
"@jambonz/realtimedb-helpers": "^0.6.5", "@jambonz/realtimedb-helpers": "^0.8.6",
"@jambonz/stats-collector": "^0.1.6", "@jambonz/speech-utils": "^0.0.21",
"@jambonz/time-series": "^0.2.5", "@jambonz/stats-collector": "^0.1.9",
"@jambonz/verb-specifications": "^0.0.3", "@jambonz/time-series": "^0.2.8",
"@jambonz/verb-specifications": "^0.0.37",
"@opentelemetry/api": "^1.4.0", "@opentelemetry/api": "^1.4.0",
"@opentelemetry/exporter-jaeger": "^1.9.0", "@opentelemetry/exporter-jaeger": "^1.9.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.35.0", "@opentelemetry/exporter-trace-otlp-http": "^0.35.0",
@@ -39,12 +43,11 @@
"@opentelemetry/sdk-trace-base": "^1.9.0", "@opentelemetry/sdk-trace-base": "^1.9.0",
"@opentelemetry/sdk-trace-node": "^1.9.0", "@opentelemetry/sdk-trace-node": "^1.9.0",
"@opentelemetry/semantic-conventions": "^1.9.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.18", "drachtio-fsmrf": "^3.0.27",
"drachtio-srf": "^4.5.23", "drachtio-srf": "^4.5.29",
"express": "^4.18.2", "express": "^4.18.2",
"ip": "^1.1.8", "ip": "^1.1.8",
"moment": "^2.29.4", "moment": "^2.29.4",
@@ -56,11 +59,11 @@
"short-uuid": "^4.2.2", "short-uuid": "^4.2.2",
"sinon": "^15.0.1", "sinon": "^15.0.1",
"to-snake-case": "^1.0.0", "to-snake-case": "^1.0.0",
"undici": "^5.16.0", "undici": "^5.19.1",
"uuid-random": "^1.3.2", "uuid-random": "^1.3.2",
"verify-aws-sns-signature": "^0.1.0", "verify-aws-sns-signature": "^0.1.0",
"ws": "^8.9.0", "ws": "^8.9.0",
"xml2js": "^0.4.23" "xml2js": "^0.6.2"
}, },
"devDependencies": { "devDependencies": {
"clear-module": "^4.1.2", "clear-module": "^4.1.2",

View File

@@ -39,7 +39,7 @@ test('\'config: listen\'', async(t) => {
} }
]; ];
provisionCallHook(from, verbs); await provisionCallHook(from, verbs);
// THEN // THEN
await sippUac('uac-gather-account-creds-success-send-bye.xml', '172.38.0.10', from); await sippUac('uac-gather-account-creds-success-send-bye.xml', '172.38.0.10', from);
@@ -86,7 +86,7 @@ test('\'config: listen - stop\'', async(t) => {
} }
]; ];
provisionCallHook(from, verbs); await provisionCallHook(from, verbs);
// THEN // THEN
await sippUac('uac-gather-account-creds-success-send-bye.xml', '172.38.0.10', from); await sippUac('uac-gather-account-creds-success-send-bye.xml', '172.38.0.10', from);

View File

@@ -37,7 +37,7 @@ test('test create-call timeout', async(t) => {
'account_sid':account_sid, 'account_sid':account_sid,
'timeout': 1, 'timeout': 1,
"call_hook": { "call_hook": {
"url": "https://public-apps.jambonz.us/hello-world", "url": "https://public-apps.jambonz.cloud/hello-world",
"method": "POST" "method": "POST"
}, },
"from": "15083718299", "from": "15083718299",
@@ -88,11 +88,11 @@ test('test create-call call-hook basic authentication', async(t) => {
let verbs = [ let verbs = [
{ {
"verb": "say", "verb": "pause",
"text": "hello" "length": 1
} }
]; ];
provisionCallHook(from, verbs); await provisionCallHook(from, verbs);
//THEN //THEN
await p; await p;
@@ -106,3 +106,117 @@ test('test create-call call-hook basic authentication', async(t) => {
t.error(err); t.error(err);
} }
}); });
test('test create-call amd', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let from = 'create-call-amd';
let account_sid = 'bb845d4b-83a9-4cde-a6e9-50f3743bab3f';
// Give UAS app time to come up
const p = sippUac('uas.xml', '172.38.0.10', from);
await waitFor(1000);
const post = bent('http://127.0.0.1:3000/', 'POST', 'json', 201);
post('v1/createCall', {
'account_sid':account_sid,
"call_hook": {
"url": "http://127.0.0.1:3100/",
"method": "POST",
"username": "username",
"password": "password"
},
"from": from,
"to": {
"type": "phone",
"number": "15583084809"
},
"amd": {
"actionHook": "/actionHook"
},
"speech_recognizer_vendor": "google",
"speech_recognizer_language": "en"
});
let verbs = [
{
"verb": "pause",
"length": 7
}
];
await provisionCallHook(from, verbs);
//THEN
await p;
let obj = await getJSON(`http:127.0.0.1:3100/lastRequest/${from}_actionHook`)
t.ok(obj.body.type = 'amd_no_speech_detected',
'create-call: AMD detected');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('test create-call app_json', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let from = 'create-call-app-json';
let account_sid = 'bb845d4b-83a9-4cde-a6e9-50f3743bab3f';
// Give UAS app time to come up
const p = sippUac('uas.xml', '172.38.0.10', from);
await waitFor(1000);
const app_json = `[
{
"verb": "pause",
"length": 7
}
]`;
const post = bent('http://127.0.0.1:3000/', 'POST', 'json', 201);
post('v1/createCall', {
'account_sid':account_sid,
"call_hook": {
"url": "http://127.0.0.1:3100/",
"method": "POST",
"username": "username",
"password": "password"
},
app_json,
"from": from,
"to": {
"type": "phone",
"number": "15583084809"
},
"amd": {
"actionHook": "/actionHook"
},
"speech_recognizer_vendor": "google",
"speech_recognizer_language": "en"
});
//THEN
await p;
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});

View File

@@ -2,6 +2,14 @@ const test = require('tape') ;
const exec = require('child_process').exec ; const exec = require('child_process').exec ;
const fs = require('fs'); const fs = require('fs');
const {encrypt} = require('../lib/utils/encrypt-decrypt'); const {encrypt} = require('../lib/utils/encrypt-decrypt');
const {
GCP_JSON_KEY,
AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY,
AWS_REGION,
MICROSOFT_REGION,
MICROSOFT_API_KEY,
} = require('../lib/config');
test('creating jambones_test database', (t) => { test('creating jambones_test database', (t) => {
exec(`mysql -h 127.0.0.1 -u root --protocol=tcp --port=3360 < ${__dirname}/db/create_test_db.sql`, (err, stdout, stderr) => { exec(`mysql -h 127.0.0.1 -u root --protocol=tcp --port=3360 < ${__dirname}/db/create_test_db.sql`, (err, stdout, stderr) => {
@@ -18,30 +26,38 @@ test('creating schema', (t) => {
if (err) return t.end(err); if (err) return t.end(err);
t.pass('schema and test data successfully created'); t.pass('schema and test data successfully created');
if (process.env.GCP_JSON_KEY && process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) { const sql = [];
const google_credential = encrypt(process.env.GCP_JSON_KEY); if (GCP_JSON_KEY) {
const google_credential = encrypt(GCP_JSON_KEY);
t.pass('adding google credentials');
sql.push(`UPDATE speech_credentials SET credential='${google_credential}' WHERE vendor='google';`);
}
if (AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY) {
const aws_credential = encrypt(JSON.stringify({ const aws_credential = encrypt(JSON.stringify({
access_key_id: process.env.AWS_ACCESS_KEY_ID, access_key_id: AWS_ACCESS_KEY_ID,
secret_access_key: process.env.AWS_SECRET_ACCESS_KEY, secret_access_key: AWS_SECRET_ACCESS_KEY,
aws_region: process.env.AWS_REGION aws_region: AWS_REGION
})); }));
t.pass('adding aws credentials');
sql.push(`UPDATE speech_credentials SET credential='${aws_credential}' WHERE vendor='aws';`);
}
if (MICROSOFT_REGION && MICROSOFT_API_KEY) {
const microsoft_credential = encrypt(JSON.stringify({ const microsoft_credential = encrypt(JSON.stringify({
region: process.env.MICROSOFT_REGION || 'useast', region: MICROSOFT_REGION,
api_key: process.env.MICROSOFT_API_KEY || '1234567890' api_key: MICROSOFT_API_KEY
})); }));
const cmd = ` t.pass('adding microsoft credentials');
UPDATE speech_credentials SET credential='${google_credential}' WHERE vendor='google'; sql.push(`UPDATE speech_credentials SET credential='${microsoft_credential}' WHERE vendor='microsoft';`);
UPDATE speech_credentials SET credential='${aws_credential}' WHERE vendor='aws'; }
UPDATE speech_credentials SET credential='${microsoft_credential}' WHERE vendor='microsoft'; if (sql.length > 0) {
`;
const path = `${__dirname}/.creds.sql`; const path = `${__dirname}/.creds.sql`;
fs.writeFileSync(path, cmd); const cmd = sql.join('\n');
fs.writeFileSync(path, sql.join('\n'));
exec(`mysql -h 127.0.0.1 -u root --protocol=tcp --port=3360 -D jambones_test < ${path}`, (err, stdout, stderr) => { exec(`mysql -h 127.0.0.1 -u root --protocol=tcp --port=3360 -D jambones_test < ${path}`, (err, stdout, stderr) => {
console.log(stdout); console.log(stdout);
console.log(stderr); console.log(stderr);
if (err) return t.end(err); if (err) return t.end(err);
fs.unlinkSync(path) fs.unlinkSync(path)
fs.writeFileSync(`${__dirname}/credentials/gcp.json`, process.env.GCP_JSON_KEY);
t.pass('set account-level speech credentials'); t.pass('set account-level speech credentials');
t.end(); t.end();
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
/* SQLEditor (MySQL (2))*/ /* SQLEditor (MySQL (2))*/
SET FOREIGN_KEY_CHECKS=0; SET FOREIGN_KEY_CHECKS=0;
DROP TABLE IF EXISTS account_static_ips; DROP TABLE IF EXISTS account_static_ips;
@@ -14,8 +13,12 @@ DROP TABLE IF EXISTS beta_invite_codes;
DROP TABLE IF EXISTS call_routes; DROP TABLE IF EXISTS call_routes;
DROP TABLE IF EXISTS clients;
DROP TABLE IF EXISTS dns_records; DROP TABLE IF EXISTS dns_records;
DROP TABLE IF EXISTS lcr;
DROP TABLE IF EXISTS lcr_carrier_set_entry; DROP TABLE IF EXISTS lcr_carrier_set_entry;
DROP TABLE IF EXISTS lcr_routes; DROP TABLE IF EXISTS lcr_routes;
@@ -52,6 +55,8 @@ DROP TABLE IF EXISTS smpp_addresses;
DROP TABLE IF EXISTS speech_credentials; DROP TABLE IF EXISTS speech_credentials;
DROP TABLE IF EXISTS system_information;
DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS smpp_gateways; DROP TABLE IF EXISTS smpp_gateways;
@@ -124,6 +129,16 @@ application_sid CHAR(36) NOT NULL,
PRIMARY KEY (call_route_sid) PRIMARY KEY (call_route_sid)
) COMMENT='a regex-based pattern match for call routing'; ) COMMENT='a regex-based pattern match for call routing';
CREATE TABLE clients
(
client_sid CHAR(36) NOT NULL UNIQUE ,
account_sid CHAR(36) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT 1,
username VARCHAR(64),
password VARCHAR(1024),
PRIMARY KEY (client_sid)
);
CREATE TABLE dns_records CREATE TABLE dns_records
( (
dns_record_sid CHAR(36) NOT NULL UNIQUE , dns_record_sid CHAR(36) NOT NULL UNIQUE ,
@@ -136,11 +151,23 @@ PRIMARY KEY (dns_record_sid)
CREATE TABLE lcr_routes CREATE TABLE lcr_routes
( (
lcr_route_sid CHAR(36), lcr_route_sid CHAR(36),
lcr_sid CHAR(36) NOT NULL,
regex VARCHAR(32) NOT NULL COMMENT 'regex-based pattern match against dialed number, used for LCR routing of PSTN calls', regex VARCHAR(32) NOT NULL COMMENT 'regex-based pattern match against dialed number, used for LCR routing of PSTN calls',
description VARCHAR(1024), description VARCHAR(1024),
priority INTEGER NOT NULL UNIQUE COMMENT 'lower priority routes are attempted first', priority INTEGER NOT NULL COMMENT 'lower priority routes are attempted first',
PRIMARY KEY (lcr_route_sid) PRIMARY KEY (lcr_route_sid)
) COMMENT='Least cost routing table'; ) COMMENT='An ordered list of digit patterns in an LCR table. The patterns are tested in sequence until one matches';
CREATE TABLE lcr
(
lcr_sid CHAR(36) NOT NULL UNIQUE ,
name VARCHAR(64) COMMENT 'User-assigned name for this LCR table',
is_active BOOLEAN NOT NULL DEFAULT 1,
default_carrier_set_entry_sid CHAR(36) COMMENT 'default carrier/route to use when no digit match based results are found.',
service_provider_sid CHAR(36),
account_sid CHAR(36),
PRIMARY KEY (lcr_sid)
) COMMENT='An LCR (least cost routing) table that is used by a service provider or account to make decisions about routing outbound calls when multiple carriers are available.';
CREATE TABLE password_settings CREATE TABLE password_settings
( (
@@ -248,7 +275,10 @@ CREATE TABLE sbc_addresses
sbc_address_sid CHAR(36) NOT NULL UNIQUE , sbc_address_sid CHAR(36) NOT NULL UNIQUE ,
ipv4 VARCHAR(255) NOT NULL, ipv4 VARCHAR(255) NOT NULL,
port INTEGER NOT NULL DEFAULT 5060, port INTEGER NOT NULL DEFAULT 5060,
tls_port INTEGER,
wss_port INTEGER,
service_provider_sid CHAR(36), service_provider_sid CHAR(36),
last_updated DATETIME,
PRIMARY KEY (sbc_address_sid) PRIMARY KEY (sbc_address_sid)
); );
@@ -304,9 +334,17 @@ last_tested DATETIME,
tts_tested_ok BOOLEAN, tts_tested_ok BOOLEAN,
stt_tested_ok BOOLEAN, stt_tested_ok BOOLEAN,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
label VARCHAR(64),
PRIMARY KEY (speech_credential_sid) PRIMARY KEY (speech_credential_sid)
); );
CREATE TABLE system_information
(
domain_name VARCHAR(255),
sip_domain_name VARCHAR(255),
monitoring_domain_name VARCHAR(255)
);
CREATE TABLE users CREATE TABLE users
( (
user_sid CHAR(36) NOT NULL UNIQUE , user_sid CHAR(36) NOT NULL UNIQUE ,
@@ -357,6 +395,7 @@ smpp_inbound_password VARCHAR(64),
register_from_user VARCHAR(128), register_from_user VARCHAR(128),
register_from_domain VARCHAR(255), register_from_domain VARCHAR(255),
register_public_ip_in_contact BOOLEAN NOT NULL DEFAULT false, register_public_ip_in_contact BOOLEAN NOT NULL DEFAULT false,
register_status VARCHAR(4096),
PRIMARY KEY (voip_carrier_sid) PRIMARY KEY (voip_carrier_sid)
) COMMENT='A Carrier or customer PBX that can send or receive calls'; ) COMMENT='A Carrier or customer PBX that can send or receive calls';
@@ -385,7 +424,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(132) NOT NULL UNIQUE , number VARCHAR(132) NOT NULL,
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),
@@ -403,6 +442,7 @@ inbound BOOLEAN NOT NULL COMMENT 'if true, whitelist this IP to allow inbound ca
outbound BOOLEAN NOT NULL COMMENT 'if true, include in least-cost routing when placing calls to the PSTN', outbound BOOLEAN NOT NULL COMMENT 'if true, include in least-cost routing when placing calls to the PSTN',
voip_carrier_sid CHAR(36) NOT NULL, voip_carrier_sid CHAR(36) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT 1, is_active BOOLEAN NOT NULL DEFAULT 1,
protocol ENUM('udp','tcp','tls', 'tls/srtp') DEFAULT 'udp' COMMENT 'Outbound call protocol',
PRIMARY KEY (sip_gateway_sid) PRIMARY KEY (sip_gateway_sid)
) COMMENT='A whitelisted sip gateway used for origination/termination'; ) COMMENT='A whitelisted sip gateway used for origination/termination';
@@ -435,13 +475,16 @@ 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), app_json TEXT,
speech_synthesis_vendor VARCHAR(64) NOT NULL DEFAULT 'google', speech_synthesis_vendor VARCHAR(64) NOT NULL DEFAULT 'google',
speech_synthesis_language VARCHAR(12) NOT NULL DEFAULT 'en-US', speech_synthesis_language VARCHAR(12) NOT NULL DEFAULT 'en-US',
speech_synthesis_voice VARCHAR(64), speech_synthesis_voice VARCHAR(64),
speech_synthesis_label VARCHAR(64),
speech_recognizer_vendor VARCHAR(64) NOT NULL DEFAULT 'google', speech_recognizer_vendor VARCHAR(64) NOT NULL DEFAULT 'google',
speech_recognizer_language VARCHAR(64) NOT NULL DEFAULT 'en-US', speech_recognizer_language VARCHAR(64) NOT NULL DEFAULT 'en-US',
speech_recognizer_label VARCHAR(64),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
record_all_calls BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (application_sid) PRIMARY KEY (application_sid)
) COMMENT='A defined set of behaviors to be applied to phone calls '; ) COMMENT='A defined set of behaviors to be applied to phone calls ';
@@ -479,6 +522,9 @@ subspace_client_secret VARCHAR(255),
subspace_sip_teleport_id VARCHAR(255), subspace_sip_teleport_id VARCHAR(255),
subspace_sip_teleport_destinations VARCHAR(255), subspace_sip_teleport_destinations VARCHAR(255),
siprec_hook_sid CHAR(36), siprec_hook_sid CHAR(36),
record_all_calls BOOLEAN NOT NULL DEFAULT false,
record_format VARCHAR(16) NOT NULL DEFAULT 'mp3',
bucket_credential VARCHAR(8192) COMMENT 'credential used to authenticate with storage service',
PRIMARY KEY (account_sid) PRIMARY KEY (account_sid)
) COMMENT='An enterprise that uses the platform for comm services'; ) COMMENT='An enterprise that uses the platform for comm services';
@@ -499,9 +545,20 @@ ALTER TABLE call_routes ADD FOREIGN KEY account_sid_idxfk_3 (account_sid) REFERE
ALTER TABLE call_routes ADD FOREIGN KEY application_sid_idxfk (application_sid) REFERENCES applications (application_sid); ALTER TABLE call_routes ADD FOREIGN KEY application_sid_idxfk (application_sid) REFERENCES applications (application_sid);
CREATE INDEX client_sid_idx ON clients (client_sid);
ALTER TABLE clients ADD CONSTRAINT account_sid_idxfk_13 FOREIGN KEY account_sid_idxfk_13 (account_sid) REFERENCES accounts (account_sid);
CREATE INDEX dns_record_sid_idx ON dns_records (dns_record_sid); CREATE INDEX dns_record_sid_idx ON dns_records (dns_record_sid);
ALTER TABLE dns_records ADD FOREIGN KEY account_sid_idxfk_4 (account_sid) REFERENCES accounts (account_sid); ALTER TABLE dns_records ADD FOREIGN KEY account_sid_idxfk_4 (account_sid) REFERENCES accounts (account_sid);
CREATE INDEX lcr_sid_idx ON lcr_routes (lcr_sid);
ALTER TABLE lcr_routes ADD FOREIGN KEY lcr_sid_idxfk (lcr_sid) REFERENCES lcr (lcr_sid);
CREATE INDEX lcr_sid_idx ON lcr (lcr_sid);
ALTER TABLE lcr ADD FOREIGN KEY default_carrier_set_entry_sid_idxfk (default_carrier_set_entry_sid) REFERENCES lcr_carrier_set_entry (lcr_carrier_set_entry_sid);
CREATE INDEX service_provider_sid_idx ON lcr (service_provider_sid);
CREATE INDEX account_sid_idx ON lcr (account_sid);
CREATE INDEX permission_sid_idx ON permissions (permission_sid); CREATE INDEX 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);
@@ -555,8 +612,6 @@ 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_4 (service_provider_sid) REFERENCES service_providers (service_provider_sid); ALTER TABLE smpp_addresses ADD FOREIGN KEY service_provider_sid_idxfk_4 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
CREATE UNIQUE INDEX speech_credentials_idx_1 ON speech_credentials (vendor,account_sid);
CREATE INDEX speech_credential_sid_idx ON speech_credentials (speech_credential_sid); CREATE INDEX 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_5 (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);
@@ -593,6 +648,8 @@ 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);
CREATE UNIQUE INDEX phone_numbers_unique_idx_voip_carrier_number ON phone_numbers (number,voip_carrier_sid);
CREATE INDEX phone_number_sid_idx ON phone_numbers (phone_number_sid); CREATE INDEX phone_number_sid_idx ON phone_numbers (phone_number_sid);
CREATE INDEX number_idx ON phone_numbers (number); CREATE INDEX 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);
@@ -647,5 +704,4 @@ 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);
ALTER TABLE accounts ADD FOREIGN KEY siprec_hook_sid_idxfk (siprec_hook_sid) REFERENCES applications (application_sid); ALTER TABLE accounts ADD FOREIGN KEY siprec_hook_sid_idxfk (siprec_hook_sid) REFERENCES applications (application_sid);
SET FOREIGN_KEY_CHECKS=1; SET FOREIGN_KEY_CHECKS=1;

View File

@@ -5,6 +5,8 @@ const getJSON = bent('json')
const clearModule = require('clear-module'); const clearModule = require('clear-module');
const {provisionCallHook} = require('./utils') const {provisionCallHook} = require('./utils')
const sleepFor = (ms) => new Promise((r) => setTimeout(r, ms));
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);
}); });
@@ -31,6 +33,7 @@ test('\'dial-phone\'', async(t) => {
{ {
"verb": "dial", "verb": "dial",
"callerId": from, "callerId": from,
"callerName": "test_callerName",
"actionHook": "/actionHook", "actionHook": "/actionHook",
"timeLimit": 5, "timeLimit": 5,
"target": [ "target": [
@@ -42,10 +45,11 @@ test('\'dial-phone\'', async(t) => {
} }
]; ];
provisionCallHook(from, verbs); await provisionCallHook(from, verbs);
// THEN // THEN
const p = sippUac('uas-dial.xml', '172.38.0.10', undefined, undefined, 2); const p = sippUac('uas-dial.xml', '172.38.0.10', undefined, undefined, 2);
await sleepFor(1000);
let account_sid = '622f62e4-303a-49f2-bbe0-eb1e1714e37a'; let account_sid = '622f62e4-303a-49f2-bbe0-eb1e1714e37a';
let post = bent('http://127.0.0.1:3000/', 'POST', 'json', 201); let post = bent('http://127.0.0.1:3000/', 'POST', 'json', 201);
@@ -56,6 +60,7 @@ test('\'dial-phone\'', async(t) => {
"method": "POST", "method": "POST",
}, },
"from": from, "from": from,
"callerName": "Tom",
"to": { "to": {
"type": "phone", "type": "phone",
"number": "15583084808" "number": "15583084808"
@@ -82,7 +87,7 @@ test('\'dial-sip\'', async(t) => {
try { try {
await connect(srf); await connect(srf);
// wait for fs connected to drachtio server. // wait for fs connected to drachtio server.
await new Promise(r => setTimeout(r, 1000)); await sleepFor(1000);
// GIVEN // GIVEN
const from = "dial_sip"; const from = "dial_sip";
let verbs = [ let verbs = [
@@ -100,7 +105,7 @@ test('\'dial-sip\'', async(t) => {
} }
]; ];
provisionCallHook(from, verbs); await provisionCallHook(from, verbs);
// THEN // THEN
const p = sippUac('uas-dial.xml', '172.38.0.10', undefined, undefined, 2); const p = sippUac('uas-dial.xml', '172.38.0.10', undefined, undefined, 2);
@@ -167,7 +172,7 @@ test('\'dial-user\'', async(t) => {
} }
]; ];
provisionCallHook(from, verbs); await provisionCallHook(from, verbs);
// THEN // THEN
const p = sippUac('uas-dial.xml', '172.38.0.10', undefined, undefined, 2); const p = sippUac('uas-dial.xml', '172.38.0.10', undefined, undefined, 2);

View File

@@ -42,7 +42,7 @@ services:
ipv4_address: 172.38.0.7 ipv4_address: 172.38.0.7
drachtio: drachtio:
image: drachtio/drachtio-server:latest image: drachtio/drachtio-server:0.8.22
restart: always restart: always
command: drachtio --contact "sip:*;transport=udp" --mtu 4096 --address 0.0.0.0 --port 9022 command: drachtio --contact "sip:*;transport=udp" --mtu 4096 --address 0.0.0.0 --port 9022
ports: ports:
@@ -57,7 +57,7 @@ services:
condition: service_healthy condition: service_healthy
freeswitch: freeswitch:
image: drachtio/drachtio-freeswitch-mrf:0.4.18 image: drachtio/drachtio-freeswitch-mrf:0.4.33
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:

View File

@@ -4,6 +4,15 @@ 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') const {provisionCallHook} = require('./utils')
const {
GCP_JSON_KEY,
AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY,
SONIOX_API_KEY,
DEEPGRAM_API_KEY,
MICROSOFT_REGION,
MICROSOFT_API_KEY,
} = require('../lib/config');
process.on('unhandledRejection', (reason, p) => { process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
@@ -18,7 +27,7 @@ function connect(connectable) {
} }
test('\'gather\' test - google', async(t) => { test('\'gather\' test - google', async(t) => {
if (!process.env.GCP_JSON_KEY) { if (!GCP_JSON_KEY) {
t.pass('skipping google tests'); t.pass('skipping google tests');
return t.end(); return t.end();
} }
@@ -41,7 +50,7 @@ test('\'gather\' test - google', async(t) => {
} }
]; ];
let from = "gather_success"; let from = "gather_success";
provisionCallHook(from, verbs); await provisionCallHook(from, verbs);
// THEN // THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from); 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`); let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
@@ -58,7 +67,7 @@ test('\'gather\' test - google', async(t) => {
}); });
test('\'gather\' test - default (google)', async(t) => { test('\'gather\' test - default (google)', async(t) => {
if (!process.env.GCP_JSON_KEY) { if (!GCP_JSON_KEY) {
t.pass('skipping google tests'); t.pass('skipping google tests');
return t.end(); return t.end();
} }
@@ -77,7 +86,7 @@ test('\'gather\' test - default (google)', async(t) => {
} }
]; ];
let from = "gather_success"; let from = "gather_success";
provisionCallHook(from, verbs); await provisionCallHook(from, verbs);
// THEN // THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from); 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`); let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
@@ -93,8 +102,55 @@ test('\'gather\' test - default (google)', async(t) => {
} }
}); });
test('\'config\' test - reset to app defaults', async(t) => {
if (!GCP_JSON_KEY) {
t.pass('skipping config tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "config",
"recognizer": {
"vendor": "google",
"language": "fr-FR"
},
},
{
"verb": "config",
"reset": ['recognizer'],
},
{
"verb": "gather",
"input": ["speech"],
"timeout": 10,
"actionHook": "/actionHook"
}
];
let from = "gather_success";
await provisionCallHook(from, verbs);
// THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
//console.log(JSON.stringify(obj));
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase() === 'i\'d like to speak to customer support',
'config: resets recognizer to app defaults');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'gather\' test - microsoft', async(t) => { test('\'gather\' test - microsoft', async(t) => {
if (!process.env.MICROSOFT_REGION || !process.env.MICROSOFT_API_KEY) { if (!MICROSOFT_REGION || !MICROSOFT_API_KEY) {
t.pass('skipping microsoft tests'); t.pass('skipping microsoft tests');
return t.end(); return t.end();
} }
@@ -117,7 +173,7 @@ test('\'gather\' test - microsoft', async(t) => {
} }
]; ];
let from = "gather_success"; let from = "gather_success";
provisionCallHook(from, verbs); await provisionCallHook(from, verbs);
// THEN // THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from); 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`); let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
@@ -134,7 +190,7 @@ test('\'gather\' test - microsoft', async(t) => {
}); });
test('\'gather\' test - aws', async(t) => { test('\'gather\' test - aws', async(t) => {
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) { if (!AWS_ACCESS_KEY_ID || !AWS_SECRET_ACCESS_KEY) {
t.pass('skipping aws tests'); t.pass('skipping aws tests');
return t.end(); return t.end();
} }
@@ -157,7 +213,7 @@ test('\'gather\' test - aws', async(t) => {
} }
]; ];
let from = "gather_success"; let from = "gather_success";
provisionCallHook(from, verbs); await provisionCallHook(from, verbs);
// THEN // THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from); 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`); let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
@@ -174,7 +230,7 @@ test('\'gather\' test - aws', async(t) => {
}); });
test('\'gather\' test - deepgram', async(t) => { test('\'gather\' test - deepgram', async(t) => {
if (!process.env.DEEPGRAM_API_KEY ) { if (!DEEPGRAM_API_KEY ) {
t.pass('skipping deepgram tests'); t.pass('skipping deepgram tests');
return t.end(); return t.end();
} }
@@ -192,7 +248,7 @@ test('\'gather\' test - deepgram', async(t) => {
"vendor": "deepgram", "vendor": "deepgram",
"hints": ["customer support", "sales", "human resources", "HR"], "hints": ["customer support", "sales", "human resources", "HR"],
"deepgramOptions": { "deepgramOptions": {
"apiKey": process.env.DEEPGRAM_API_KEY "apiKey": DEEPGRAM_API_KEY
} }
}, },
"timeout": 10, "timeout": 10,
@@ -200,13 +256,55 @@ test('\'gather\' test - deepgram', async(t) => {
} }
]; ];
let from = "gather_success"; let from = "gather_success";
provisionCallHook(from, verbs); await provisionCallHook(from, verbs);
// THEN // THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from); 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`); let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
//console.log(JSON.stringify(obj)); //console.log(JSON.stringify(obj));
t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().includes('like to speak to customer support'),
'gather: succeeds when using deepgram credentials');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'gather\' test - soniox', async(t) => {
if (!SONIOX_API_KEY ) {
t.pass('skipping soniox tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "gather",
"input": ["speech"],
"recognizer": {
"vendor": "deepgram",
"hints": ["customer support", "sales", "human resources", "HR"],
"deepgramOptions": {
"apiKey": SONIOX_API_KEY
}
},
"timeout": 10,
"actionHook": "/actionHook"
}
];
let from = "gather_success";
await 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'), t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().startsWith('i\'d like to speak to customer support'),
'gather: succeeds when using deepgram credentials'); 'gather: succeeds when using soniox credentials');
disconnect(); disconnect();
} catch (err) { } catch (err) {

65
test/in-dialog-test.js Normal file
View File

@@ -0,0 +1,65 @@
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('\'sip Indialog\' test Info', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
const verbs = [
{
verb: 'config',
sipRequestWithinDialogHook: '/customHook'
},
{
verb: 'play',
url: 'silence_stream://5000',
}
];
const waitHookVerbs = [
{
verb: 'hangup'
}
];
const from = 'sip_indialog_info';
await provisionCustomHook(from, waitHookVerbs)
await provisionCallHook(from, verbs);
// THEN
await sippUac('uac-success-info-received-bye.xml', '172.38.0.10', from);
t.pass('sip Info: success send Info');
// Make sure that sipRequestWithinDialogHook is called and success
const json = await getJSON(`http:127.0.0.1:3100/lastRequest/${from}_customHook`)
t.pass(json.body.sip_method === 'INFO', 'sipRequestWithinDialogHook contains sip_method')
t.pass(json.body.sip_body === 'hello jambonz\r\n', 'sipRequestWithinDialogHook contains sip_method')
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});

View File

@@ -14,5 +14,7 @@ require('./play-tests');
require('./sip-refer-tests'); require('./sip-refer-tests');
require('./listen-tests'); require('./listen-tests');
require('./config-test'); require('./config-test');
require('./queue-test');
require('./in-dialog-test');
require('./remove-test-db'); require('./remove-test-db');
require('./docker_stop'); require('./docker_stop');

View File

@@ -35,7 +35,7 @@ test('\'listen-success\'', async(t) => {
} }
]; ];
provisionCallHook(from, verbs); await provisionCallHook(from, verbs);
// THEN // THEN
await sippUac('uac-gather-account-creds-success-send-bye.xml', '172.38.0.10', from); await sippUac('uac-gather-account-creds-success-send-bye.xml', '172.38.0.10', from);
@@ -57,7 +57,7 @@ test('\'listen-success\'', async(t) => {
} }
}); });
test('\'listen-maxLength\'', async(t) => { test.skip('\'listen-maxLength\'', async(t) => {
clearModule.all(); clearModule.all();
const {srf, disconnect} = require('../app'); const {srf, disconnect} = require('../app');
try { try {
@@ -69,13 +69,13 @@ test('\'listen-maxLength\'', async(t) => {
{ {
"verb": "listen", "verb": "listen",
"url": `ws://172.38.0.60:3000/${from}`, "url": `ws://172.38.0.60:3000/${from}`,
"mixType" : "mixed", "mixType" : "stereo",
"timeout": 2, "timeout": 2,
"maxLength": 2 "maxLength": 2
} }
]; ];
provisionCallHook(from, verbs); await provisionCallHook(from, verbs);
// THEN // THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from); await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);
@@ -109,7 +109,7 @@ test('\'listen-pause-resume\'', async(t) => {
} }
]; ];
provisionCallHook(from, verbs); await provisionCallHook(from, verbs);
// THEN // THEN
const p = sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from); const p = sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from);

View File

@@ -33,7 +33,7 @@ test('\'play\' tests single link in plain text', async(t) => {
]; ];
const from = 'play_single_link'; const from = 'play_single_link';
provisionCallHook(from, verbs) await provisionCallHook(from, verbs)
// THEN // THEN
await sippUac('uac-success-received-bye.xml', '172.38.0.10', from); await sippUac('uac-success-received-bye.xml', '172.38.0.10', from);
@@ -62,7 +62,7 @@ test('\'play\' tests multi links in array', async(t) => {
]; ];
const from = 'play_multi_links_in_array'; const from = 'play_multi_links_in_array';
provisionCallHook(from, verbs) await provisionCallHook(from, verbs)
// THEN // THEN
await sippUac('uac-success-received-bye.xml', '172.38.0.10', from); await sippUac('uac-success-received-bye.xml', '172.38.0.10', from);
@@ -100,8 +100,8 @@ test('\'play\' tests single link in conference', async(t) => {
waitHook: `/customHook` waitHook: `/customHook`
} }
]; ];
provisionCustomHook(from, waitHookVerbs) await provisionCustomHook(from, waitHookVerbs)
provisionCallHook(from, verbs) await provisionCallHook(from, verbs)
// THEN // THEN
await sippUac('uac-success-send-bye.xml', '172.38.0.10', from); await sippUac('uac-success-send-bye.xml', '172.38.0.10', from);
@@ -141,8 +141,8 @@ test('\'play\' tests multi links in array in conference', async(t) => {
waitHook: `/customHook` waitHook: `/customHook`
} }
]; ];
provisionCustomHook(from, waitHookVerbs) await provisionCustomHook(from, waitHookVerbs)
provisionCallHook(from, verbs) await provisionCallHook(from, verbs)
// THEN // THEN
await sippUac('uac-success-send-bye.xml', '172.38.0.10', from); await sippUac('uac-success-send-bye.xml', '172.38.0.10', from);
@@ -178,8 +178,8 @@ test('\'play\' tests with seekOffset and actionHook', async(t) => {
const waitHookVerbs = []; const waitHookVerbs = [];
const from = 'play_action_hook'; const from = 'play_action_hook';
provisionCallHook(from, verbs) await provisionCallHook(from, verbs)
provisionCustomHook(from, waitHookVerbs) await provisionCustomHook(from, waitHookVerbs)
// THEN // THEN
await sippUac('uac-success-received-bye.xml', '172.38.0.10', from); await sippUac('uac-success-received-bye.xml', '172.38.0.10', from);
@@ -218,7 +218,7 @@ test('\'play\' tests with earlymedia', async(t) => {
]; ];
const from = 'play_early_media'; const from = 'play_early_media';
provisionCallHook(from, verbs) await provisionCallHook(from, verbs)
// THEN // THEN
await sippUac('uac-invite-expect-183-cancel.xml', '172.38.0.10', from); await sippUac('uac-invite-expect-183-cancel.xml', '172.38.0.10', from);

127
test/queue-test.js Normal file
View File

@@ -0,0 +1,127 @@
const test = require('tape');
const { sippUac } = require('./sipp')('test_fs');
const clearModule = require('clear-module');
const {provisionCallHook, provisionActionHook, provisionAnyHook} = 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();
});
});
}
const sleepFor = (ms) => new Promise((resolve) => setTimeout(() => resolve(), ms));
test('\'enqueue-dequeue\' tests', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
const verbs = [
{
verb: 'enqueue',
name: 'support',
actionHook: '/actionHook'
}
];
const verbs2 = [
{
verb: 'dequeue',
name: 'support'
}
];
const actionVerbs = [
{
verb: 'play',
url: 'silence_stream://1000',
earlyMedia: true
}
];
const from = 'enqueue_success';
await provisionCallHook(from, verbs);
await provisionActionHook(from, actionVerbs)
const from2 = 'dequeue_success';
await provisionCallHook(from2, verbs2);
// THEN
const p1 = sippUac('uac-success-received-bye.xml', '172.38.0.10', from);
await sleepFor(1000);
const p2 = sippUac('uac-success-send-bye.xml', '172.38.0.11', from2);
await Promise.all([p1, p2]);
const obj = await getJSON(`http:127.0.0.1:3100/lastRequest/${from}_actionHook`);
t.ok(obj.body.queue_result === 'bridged');
t.pass('enqueue-dequeue: succeeds connect');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\leave\' tests', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
const verbs = [
{
verb: 'enqueue',
name: 'support1',
waitHook: '/anyHook/enqueue_success_leave',
actionHook: '/actionHook'
}
];
const anyHookVerbs = [
{
verb: 'leave'
}
];
const actionVerbs = [
{
verb: 'play',
url: 'silence_stream://1000',
earlyMedia: true
}
];
const from = 'enqueue_success_leave';
await provisionCallHook(from, verbs);
await provisionAnyHook(from, anyHookVerbs);
await provisionActionHook(from, actionVerbs)
// THEN
await sippUac('uac-success-received-bye.xml', '172.38.0.10', from);
const obj = await getJSON(`http:127.0.0.1:3100/lastRequest/enqueue_success_leave`);
t.ok(obj.body.queue_position === 0);
const obj1 = await getJSON(`http:127.0.0.1:3100/lastRequest/${from}_actionHook`);
t.ok(obj1.body.queue_result === 'leave');
t.pass('enqueue-dequeue: succeeds connect');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});

View File

@@ -5,7 +5,6 @@ test('dropping jambones_test database', (t) => {
exec(`mysql -h 127.0.0.1 -u root --protocol=tcp --port=3360 < ${__dirname}/db/remove_test_db.sql`, (err, stdout, stderr) => { exec(`mysql -h 127.0.0.1 -u root --protocol=tcp --port=3360 < ${__dirname}/db/remove_test_db.sql`, (err, stdout, stderr) => {
if (err) return t.end(err); if (err) return t.end(err);
t.pass('database successfully dropped'); t.pass('database successfully dropped');
fs.unlinkSync(`${__dirname}/credentials/gcp.json`);
t.end(); t.end();
}); });
}); });

View File

@@ -31,7 +31,7 @@ test('\'say\' tests', async(t) => {
]; ];
const from = 'say_test_success'; const from = 'say_test_success';
provisionCallHook(from, verbs) await provisionCallHook(from, verbs)
// THEN // THEN
await sippUac('uac-success-received-bye.xml', '172.38.0.10', from); await sippUac('uac-success-received-bye.xml', '172.38.0.10', from);
@@ -43,3 +43,84 @@ test('\'say\' tests', async(t) => {
t.error(err); t.error(err);
} }
}); });
test('\'config\' reset synthesizer tests', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
const verbs = [
{
"verb": "config",
"synthesizer": {
"vendor": "microsft",
"voice": "foobar"
},
},
{
"verb": "config",
"reset": 'synthesizer',
},
{
verb: 'say',
text: 'hello'
}
];
const from = 'say_test_success';
await provisionCallHook(from, verbs)
// THEN
await sippUac('uac-success-received-bye.xml', '172.38.0.10', from);
t.pass('say: succeeds when using using account credentials');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
const {MICROSOFT_CUSTOM_API_KEY, MICROSOFT_DEPLOYMENT_ID, MICROSOFT_CUSTOM_REGION, MICROSOFT_CUSTOM_VOICE} = process.env;
if (MICROSOFT_CUSTOM_API_KEY && MICROSOFT_DEPLOYMENT_ID && MICROSOFT_CUSTOM_REGION && MICROSOFT_CUSTOM_VOICE) {
test('\'say\' tests - microsoft custom voice', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
const verbs = [
{
verb: 'say',
text: 'hello',
synthesizer: {
vendor: 'microsoft',
voice: MICROSOFT_CUSTOM_VOICE,
options: {
deploymentId: MICROSOFT_DEPLOYMENT_ID,
apiKey: MICROSOFT_CUSTOM_API_KEY,
region: MICROSOFT_CUSTOM_REGION,
}
}
}
];
const from = 'say_test_success';
await provisionCallHook(from, verbs)
// THEN
await sippUac('uac-success-received-bye.xml', '172.38.0.10', from);
t.pass('say: succeeds when using microsoft custom voice');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
}

View File

@@ -0,0 +1,114 @@
<?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="2000"/>
<!-- Send an INFO message -->
<send>
<![CDATA[
INFO sip:[service]@[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 INFO
Contact: sip:[from]@[local_ip]:[local_port]
Max-Forwards: 70
Subject: Performance Test
Content-Type: text/plain
Content-Length: [len]
hello jambonz
]]>
</send>
<!-- Receive 200 OK -->
<recv response="200">
</recv>
<recv request="BYE">
</recv>
<send>
<![CDATA[
SIP/2.0 200 OK
[last_Via:]
[last_From:]
[last_To:]
[last_Call-ID:]
[last_CSeq:]
Contact: <sip:[local_ip]:[local_port];transport=[transport]>
Content-Length: 0
]]>
</send>
</scenario>

View File

@@ -41,8 +41,8 @@ test('\'refer\' tests w/202 and NOTIFY', {timeout: 25000}, async(t) => {
const noVerbs = []; const noVerbs = [];
const from = 'refer_with_notify'; const from = 'refer_with_notify';
provisionCallHook(from, verbs); await provisionCallHook(from, verbs);
provisionActionHook(from, noVerbs) await provisionActionHook(from, noVerbs)
// THEN // THEN
await sippUac('uac-refer-with-notify.xml', '172.38.0.10', from); await sippUac('uac-refer-with-notify.xml', '172.38.0.10', from);
@@ -81,8 +81,8 @@ test('\'refer\' tests w/202 but no NOTIFY', {timeout: 25000}, async(t) => {
const noVerbs = []; const noVerbs = [];
const from = 'refer_no_notify'; const from = 'refer_no_notify';
provisionCallHook(from, verbs); await provisionCallHook(from, verbs);
provisionActionHook(from, noVerbs) await provisionActionHook(from, noVerbs)
// THEN // THEN
await sippUac('uac-refer-no-notify.xml', '172.38.0.10', from); await sippUac('uac-refer-no-notify.xml', '172.38.0.10', from);

View File

@@ -40,7 +40,7 @@ test('sending SIP in-dialog requests tests', async(t) => {
} }
]; ];
let from = "sip_indialog_test"; let from = "sip_indialog_test";
provisionCallHook(from, verbs); await provisionCallHook(from, verbs);
// THEN // THEN
await sippUac('uac-send-info-during-dialog.xml', '172.38.0.10', from); 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`); const obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);

View File

@@ -36,7 +36,7 @@ obj.sippUac = (file, bindAddress, from='sipp', to='16174000000', loop=1) => {
'-cid_str', `%u-%p@%s-${idx++}`, '-cid_str', `%u-%p@%s-${idx++}`,
'172.38.0.50', '172.38.0.50',
'-key','from', from, '-key','from', from,
'-key','to', to, '-trace_msg' '-key','to', to, '-trace_msg', '-trace_err'
]; ];
if (bindAddress) args.splice(5, 0, '--ip', bindAddress); if (bindAddress) args.splice(5, 0, '--ip', bindAddress);

View File

@@ -4,6 +4,15 @@ 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') const {provisionCallHook} = require('./utils')
const {
GCP_JSON_KEY,
AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY,
MICROSOFT_REGION,
MICROSOFT_API_KEY,
SONIOX_API_KEY,
DEEPGRAM_API_KEY,
} = require('../lib/config');
process.on('unhandledRejection', (reason, p) => { process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
@@ -18,7 +27,7 @@ function connect(connectable) {
} }
test('\'transcribe\' test - google', async(t) => { test('\'transcribe\' test - google', async(t) => {
if (!process.env.GCP_JSON_KEY) { if (!GCP_JSON_KEY) {
t.pass('skipping google tests'); t.pass('skipping google tests');
return t.end(); return t.end();
} }
@@ -39,7 +48,7 @@ test('\'transcribe\' test - google', async(t) => {
} }
]; ];
let from = "gather_success"; let from = "gather_success";
provisionCallHook(from, verbs); await provisionCallHook(from, verbs);
// THEN // THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from); 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`); let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
@@ -55,7 +64,7 @@ test('\'transcribe\' test - google', async(t) => {
}); });
test('\'transcribe\' test - microsoft', async(t) => { test('\'transcribe\' test - microsoft', async(t) => {
if (!process.env.MICROSOFT_REGION || !process.env.MICROSOFT_API_KEY) { if (!MICROSOFT_REGION || !MICROSOFT_API_KEY) {
t.pass('skipping microsoft tests'); t.pass('skipping microsoft tests');
return t.end(); return t.end();
} }
@@ -76,7 +85,7 @@ test('\'transcribe\' test - microsoft', async(t) => {
} }
]; ];
let from = "gather_success"; let from = "gather_success";
provisionCallHook(from, verbs); await provisionCallHook(from, verbs);
// THEN // THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from); 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`); let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
@@ -92,7 +101,7 @@ test('\'transcribe\' test - microsoft', async(t) => {
}); });
test('\'transcribe\' test - aws', async(t) => { test('\'transcribe\' test - aws', async(t) => {
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) { if (!AWS_ACCESS_KEY_ID || !AWS_SECRET_ACCESS_KEY) {
t.pass('skipping aws tests'); t.pass('skipping aws tests');
return t.end(); return t.end();
} }
@@ -113,7 +122,7 @@ test('\'transcribe\' test - aws', async(t) => {
} }
]; ];
let from = "gather_success"; let from = "gather_success";
provisionCallHook(from, verbs); await provisionCallHook(from, verbs);
// THEN // THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from); 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`); let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_actionHook`);
@@ -129,7 +138,7 @@ test('\'transcribe\' test - aws', async(t) => {
}); });
test('\'transcribe\' test - deepgram', async(t) => { test('\'transcribe\' test - deepgram', async(t) => {
if (!process.env.DEEPGRAM_API_KEY ) { if (!DEEPGRAM_API_KEY ) {
t.pass('skipping deepgram tests'); t.pass('skipping deepgram tests');
return t.end(); return t.end();
} }
@@ -143,23 +152,102 @@ test('\'transcribe\' test - deepgram', async(t) => {
{ {
"verb": "transcribe", "verb": "transcribe",
"recognizer": { "recognizer": {
"vendor": "aws", "vendor": "deepgram",
"hints": ["customer support", "sales", "human resources", "HR"], "hints": ["customer support", "sales", "human resources", "HR"],
"deepgramOptions": { "deepgramOptions": {
"apiKey": process.env.DEEPGRAM_API_KEY "apiKey": DEEPGRAM_API_KEY
} }
}, },
"transcriptionHook": "/transcriptionHook" "transcriptionHook": "/transcriptionHook"
} }
]; ];
let from = "gather_success"; let from = "gather_success";
provisionCallHook(from, verbs); await provisionCallHook(from, verbs);
// THEN // THEN
await sippUac('uac-gather-account-creds-success.xml', '172.38.0.10', from); 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`); 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'), t.ok(obj.body.speech.alternatives[0].transcript.toLowerCase().includes('like to speak to customer support'),
'transcribe: succeeds when using deepgram credentials'); 'transcribe: succeeds when using deepgram credentials');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'transcribe\' test - soniox', async(t) => {
if (!SONIOX_API_KEY ) {
t.pass('skipping soniox tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "transcribe",
"recognizer": {
"vendor": "soniox",
"hints": ["customer support", "sales", "human resources", "HR"],
"deepgramOptions": {
"apiKey": SONIOX_API_KEY
}
},
"transcriptionHook": "/transcriptionHook"
}
];
let from = "gather_success";
await 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);
}
});
test('\'transcribe\' test - google with asrTimeout', async(t) => {
if (!GCP_JSON_KEY) {
t.pass('skipping google tests');
return t.end();
}
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
let verbs = [
{
"verb": "transcribe",
"recognizer": {
"vendor": "google",
"hints": ["customer support", "sales", "human resources", "HR"],
"asrTimeout": 4
},
"transcriptionHook": "/transcriptionHook"
}
];
let from = "gather_success";
await 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(); disconnect();
} catch (err) { } catch (err) {
console.log(`error received: ${err}`); console.log(`error received: ${err}`);

View File

@@ -6,31 +6,40 @@ const bent = require('bent');
* The function help testcase to register desired jambonz json response for an application call * 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. * When a call has From number match the registered hook event, the desired jambonz json will be responded.
*/ */
const provisionCallHook = (from, verbs) => { const provisionCallHook = async (from, verbs) => {
const mapping = { const mapping = {
from, from,
data: JSON.stringify(verbs) data: JSON.stringify(verbs)
}; };
const post = bent('http://127.0.0.1:3100', 'POST', 'string', 200); const post = bent('http://127.0.0.1:3100', 'POST', 'string', 200);
post('/appMapping', mapping); await post('/appMapping', mapping);
} }
const provisionCustomHook = (from, verbs) => { const provisionCustomHook = async(from, verbs) => {
const mapping = { const mapping = {
from, from,
data: JSON.stringify(verbs) data: JSON.stringify(verbs)
}; };
const post = bent('http://127.0.0.1:3100', 'POST', 'string', 200); const post = bent('http://127.0.0.1:3100', 'POST', 'string', 200);
post(`/customHookMapping`, mapping); await post(`/customHookMapping`, mapping);
} }
const provisionActionHook = (from, verbs) => { const provisionActionHook = async(from, verbs) => {
const mapping = { const mapping = {
from, from,
data: JSON.stringify(verbs) data: JSON.stringify(verbs)
}; };
const post = bent('http://127.0.0.1:3100', 'POST', 'string', 200); const post = bent('http://127.0.0.1:3100', 'POST', 'string', 200);
post(`/actionHook`, mapping); await post(`/actionHook`, mapping);
} }
module.exports = { provisionCallHook, provisionCustomHook, provisionActionHook} const provisionAnyHook = async(key, verbs) => {
const mapping = {
key,
data: JSON.stringify(verbs)
};
const post = bent('http://127.0.0.1:3100', 'POST', 'string', 200);
await post(`/anyHookMapping`, mapping);
}
module.exports = { provisionCallHook, provisionCustomHook, provisionActionHook, provisionAnyHook}

View File

@@ -2,6 +2,7 @@ const express = require('express');
const app = express(); const app = express();
const Websocket = require('ws'); const Websocket = require('ws');
const listenPort = process.env.HTTP_PORT || 3000; const listenPort = process.env.HTTP_PORT || 3000;
const any_hook_json_mapping = new Map();
let json_mapping = new Map(); let json_mapping = new Map();
let hook_mapping = new Map(); let hook_mapping = new Map();
let ws_packet_count = new Map(); let ws_packet_count = new Map();
@@ -61,7 +62,7 @@ app.all('/', (req, res) => {
console.log(req.body, 'POST /'); console.log(req.body, 'POST /');
const key = req.body.from const key = req.body.from
addRequestToMap(key, req, hook_mapping); addRequestToMap(key, req, hook_mapping);
return getJsonFromMap(key, req, res); return getJsonFromMap(json_mapping, key, req, res);
}); });
app.post('/appMapping', (req, res) => { app.post('/appMapping', (req, res) => {
@@ -106,7 +107,7 @@ app.post('/actionHook', (req, res) => {
app.all('/customHook', (req, res) => { app.all('/customHook', (req, res) => {
let key = `${req.body.from}_customHook`;; let key = `${req.body.from}_customHook`;;
console.log(req.body, `POST /customHook`); console.log(req.body, `POST /customHook`);
return getJsonFromMap(key, req, res); return getJsonFromMap(json_mapping, key, req, res);
}); });
app.post('/customHookMapping', (req, res) => { app.post('/customHookMapping', (req, res) => {
@@ -116,6 +117,23 @@ app.post('/customHookMapping', (req, res) => {
return res.sendStatus(200); return res.sendStatus(200);
}); });
/**
* Any Hook
*/
app.all('/anyHook/:key', (req, res) => {
let key = req.params.key;
console.log(req.body, `POST /anyHook/${key}`);
return getJsonFromMap(any_hook_json_mapping, key, req, res);
});
app.post('/anyHookMapping', (req, res) => {
let key = req.body.key;
console.log(req.body, `POST /anyHookMapping/${key}`);
any_hook_json_mapping.set(key, req.body.data);
return res.sendStatus(200);
});
// Fetch Requests // Fetch Requests
app.get('/requests/:key', (req, res) => { app.get('/requests/:key', (req, res) => {
let key = req.params.key; let key = req.params.key;
@@ -162,9 +180,9 @@ app.get('/ws_metadata/:key', (req, res) => {
* private function * private function
*/ */
function getJsonFromMap(key, req, res) { function getJsonFromMap(map, key, req, res) {
if (!json_mapping.has(key)) return res.sendStatus(404); if (!map.has(key)) return res.sendStatus(404);
const retData = JSON.parse(json_mapping.get(key)); const retData = JSON.parse(map.get(key));
console.log(retData, ` Response to ${req.method} ${req.url}`); console.log(retData, ` Response to ${req.method} ${req.url}`);
addRequestToMap(key, req, hook_mapping); addRequestToMap(key, req, hook_mapping);
return res.json(retData); return res.json(retData);

View File

@@ -2,13 +2,17 @@ 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 {provisionCallHook} = require('./utils')
const {
JAMBONES_LOGLEVEL,
JAMBONES_TIME_SERIES_HOST
} = require('../lib/config');
const opts = { const opts = {
timestamp: () => {return `, "time": "${new Date().toISOString()}"`;}, timestamp: () => {return `, "time": "${new Date().toISOString()}"`;},
level: process.env.JAMBONES_LOGLEVEL || 'info' level: JAMBONES_LOGLEVEL
}; };
const logger = require('pino')(opts); const logger = require('pino')(opts);
const { queryAlerts } = require('@jambonz/time-series')( const { queryAlerts } = require('@jambonz/time-series')(
logger, process.env.JAMBONES_TIME_SERIES_HOST logger, JAMBONES_TIME_SERIES_HOST
); );
process.on('unhandledRejection', (reason, p) => { process.on('unhandledRejection', (reason, p) => {
@@ -41,7 +45,7 @@ test('basic webhook tests', async(t) => {
]; ];
const from = 'sip_decline_test_success'; const from = 'sip_decline_test_success';
provisionCallHook(from, verbs) await provisionCallHook(from, verbs)
await sippUac('uac-expect-603.xml', '172.38.0.10', from); await sippUac('uac-expect-603.xml', '172.38.0.10', from);
t.pass('webhook successfully declines call'); t.pass('webhook successfully declines call');
@@ -69,7 +73,7 @@ test('invalid jambonz json create alert tests', async(t) => {
}; };
const from = 'invalid_json_create_alert'; const from = 'invalid_json_create_alert';
provisionCallHook(from, verbs) await provisionCallHook(from, verbs)
// THEN // THEN
await sippUac('uac-invite-expect-480.xml', '172.38.0.10', from); await sippUac('uac-invite-expect-480.xml', '172.38.0.10', from);

View File

@@ -3,7 +3,10 @@ const sinon = require('sinon');
const proxyquire = require("proxyquire"); const proxyquire = require("proxyquire");
proxyquire.noCallThru(); proxyquire.noCallThru();
const MockWebsocket = require('./ws-mock') const MockWebsocket = require('./ws-mock')
const logger = require('pino')({level: process.env.JAMBONES_LOGLEVEL || 'error'}); const {
JAMBONES_LOGLEVEL,
} = require('../lib/config');
const logger = require('pino')({level: JAMBONES_LOGLEVEL});
const BaseRequestor = proxyquire( const BaseRequestor = proxyquire(
"../lib/utils/base-requestor", "../lib/utils/base-requestor",

View File

@@ -7,12 +7,16 @@ const { BatchSpanProcessor } = require('@opentelemetry/sdk-trace-base');
const { JaegerExporter } = require('@opentelemetry/exporter-jaeger'); const { JaegerExporter } = require('@opentelemetry/exporter-jaeger');
const { ZipkinExporter } = require('@opentelemetry/exporter-zipkin'); const { ZipkinExporter } = require('@opentelemetry/exporter-zipkin');
const { OTLPTraceExporter } = require ('@opentelemetry/exporter-trace-otlp-http'); const { OTLPTraceExporter } = require ('@opentelemetry/exporter-trace-otlp-http');
//const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http'); const {
//const { ExpressInstrumentation } = require('@opentelemetry/instrumentation-express'); JAMBONES_OTEL_ENABLED,
//const { PinoInstrumentation } = require('@opentelemetry/instrumentation-pino'); OTEL_EXPORTER_JAEGER_AGENT_HOST,
OTEL_EXPORTER_JAEGER_ENDPOINT,
OTEL_EXPORTER_ZIPKIN_URL,
OTEL_EXPORTER_COLLECTOR_URL
} = require('./lib/config');
module.exports = (serviceName) => { module.exports = (serviceName) => {
if (process.env.JAMBONES_OTEL_ENABLED) { if (JAMBONES_OTEL_ENABLED) {
const {version} = require('./package.json'); const {version} = require('./package.json');
const provider = new NodeTracerProvider({ const provider = new NodeTracerProvider({
resource: new Resource({ resource: new Resource({
@@ -22,15 +26,15 @@ module.exports = (serviceName) => {
}); });
let exporter; let exporter;
if (process.env.OTEL_EXPORTER_JAEGER_AGENT_HOST || process.env.OTEL_EXPORTER_JAEGER_ENDPOINT) { if (OTEL_EXPORTER_JAEGER_AGENT_HOST || OTEL_EXPORTER_JAEGER_ENDPOINT) {
exporter = new JaegerExporter(); exporter = new JaegerExporter();
} }
else if (process.env.OTEL_EXPORTER_ZIPKIN_URL) { else if (OTEL_EXPORTER_ZIPKIN_URL) {
exporter = new ZipkinExporter({url:process.env.OTEL_EXPORTER_ZIPKIN_URL}); exporter = new ZipkinExporter({url:OTEL_EXPORTER_ZIPKIN_URL});
} }
else { else {
exporter = new OTLPTraceExporter({ exporter = new OTLPTraceExporter({
url: process.OTEL_EXPORTER_COLLECTOR_URL url: OTEL_EXPORTER_COLLECTOR_URL
}); });
} }