Compare commits

..

154 Commits

Author SHA1 Message Date
oddsix
ed5921b97b incorporate parsed options 2025-11-20 13:14:24 -05:00
oddsix
94543d3a0a fix error when tts stream invoked without config 2025-11-20 13:14:24 -05:00
Hoan Luu Huu
1984b6d3ea allow say verb failed as NonFatalTaskError for File Not Found (#1443)
* allow say verb failed as NonFatalTaskError for File Not Found

* wip
2025-11-20 07:22:28 -05:00
Hoan Luu Huu
769b66f57e fixed playbackIds is not in correct order compare with say.text array (#1439)
* fixed playbackIds is not in correct order compare with say.text array

* wip

* wip
2025-11-19 19:00:44 -05:00
Hoan Luu Huu
98b845f489 fix say verb does not close streaming when finish say (#1412)
* fix say verb does not close streaming when finish say

* wip

* wip

* ttsStreamingBuffer reset eventHandlerCount after remove listeners

* only send tokens to module if connected

* wip

* sent stream_open when successfully connected to vendor
2025-11-17 08:56:09 -05:00
Ed Robbins
f92b1dbc97 Add ability to override certain tts streaming options via the config … (#1429)
* Add ability to override certain tts streaming options via the config verb.

* Update to null operator(??), support parameter override via config
2025-11-12 13:54:01 -05:00
Dave Horton
0442144793 fix bug escaping backspace character 2025-11-03 15:33:59 -05:00
Hoan Luu Huu
2de24af169 fixed gather does not start timeout on bargin (#1421)
* fixed gather does not start timeout on bargin

* with previous change, no need to emit playDone since no where in the code are we listening for it

---------

Co-authored-by: Dave Horton <daveh@beachdognet.com>
2025-11-03 13:11:59 -05:00
Dave Horton
a884880321 fix for #1422 (#1423)
* fix for #1422

* fix prev commit
2025-11-03 12:53:43 -05:00
Dave Horton
b307df79d0 update deps (#1417) 2025-10-31 07:31:32 -04:00
Hoan Luu Huu
77bd11dd47 update speech util 0.2.26 (#1416) 2025-10-31 07:14:38 -04:00
Hoan Luu Huu
46d56fe546 fd_1574: should not send only whitespace to streaming tts engine (#1415) 2025-10-30 20:59:25 -04:00
Hoan Luu Huu
30ab281ea2 support disableTtsCache from config verb (#1410) 2025-10-28 08:19:03 -04:00
Sam Machin
0869a73052 add distributeDtmf to conference (#1401)
* add distributeDtmf to conference

* lint

* bump verb specs
2025-10-21 11:20:12 -04:00
Sam Machin
a0a579ccee escape json special chars in metadata (#1399) 2025-10-20 10:30:03 -04:00
Sam Machin
4218653852 add customerData on transferred calls (#1391)
* add customerData on transferred calls

* change to if statement
2025-10-20 09:20:12 -04:00
Hoan Luu Huu
89cc39f726 support gladia stt (#1397)
* support gladia stt

* wip

* wip

* update verb specification
2025-10-20 04:56:39 -04:00
Sam Machin
b231593bff bump dbhelpers for cache change (#1396) 2025-10-15 11:38:43 -04:00
Sam Machin
4309d25376 don't encode querystring if its the filename (#1395)
* don't encode querystring if its the filename

* lint

* update link to issue

u
2025-10-14 10:48:50 -04:00
Hoan Luu Huu
a00703a067 support houndify stt (#1364)
* support houndify stt

* wip

* wip

* wip

* update houndify stt parameters

* wip

* wip
2025-10-14 00:55:21 -04:00
Hoan Luu Huu
89c985b564 fixed does not send final status call back if call canceled quickly (#1393)
* fixed callsession should cleanup resource if call was canceled while fetching app

* wip

* wip

* wip

* wip

* wip
2025-10-11 03:44:42 -04:00
Dave Horton
b4ed4c8c46 #1385: Gather - dont start the continuous asr timer when we first start listening if this is a background gather (#1386) 2025-10-09 08:47:51 -04:00
Hoan Luu Huu
581d309f36 support elevenlabs different endpoint (#1387)
* support elevenlabs different endpoint

* wip

* wip
2025-10-09 08:19:40 -04:00
Sam Machin
d1baf2fe37 if call is transferred from another FS then always answer (#1383)
Currently if the call being transferred was originally an outbound call then the direction thats retrieved from redis is outbound and the invite of the refer from the other FS is never answered,
However a transferredCall will always need to be answered regardless of CallDirection
2025-10-07 07:19:11 -04:00
Dave Horton
28bf0d3477 send eager_eot events (#1382) 2025-10-06 16:50:20 -04:00
Dave Horton
d2d3b4583e Fix/flux cleanup (#1379)
* for deepgram flux include the turn taking events in the transcription payload

* for deepgram flux, including turn_taking_event in the speech payload

* fix prev commit which used wrong field
2025-10-04 20:06:38 -04:00
Hoan Luu Huu
854c26db11 support deepgramflux (#1373)
* support deepgramflux

* wip

* wip

* wip

* wip

* update verb scpecification
2025-10-03 10:38:39 -04:00
Dave Horton
e77666a1a7 update speech-utils and drachtio-srf (#1377) 2025-10-03 10:06:37 -04:00
Ed Robbins
5acb19225b If an error occurs during initial TTS request, propagate the error (#1369)
* If an error occurs during initial TTS request, propagate the error

* fix missing semicolon

* fix jslint error

* add null check to r.playbackMilliseconds
2025-10-01 00:02:51 -04:00
Dave Horton
1d6f84c2d7 add event handler for when deepgram closes with an error (#1372) 2025-09-28 14:18:56 -04:00
Vinod Dharashive
de9b970a93 Update example-voicemail-greetings.json (#1367)
Add JP greetin
2025-09-23 09:30:40 -04:00
rammohan-y
ec786ef1dd Fix for sending synthesized-audio verb:status event when using TTS streaming (#1366)
https://github.com/jambonz/jambonz-feature-server/issues/1365
2025-09-23 09:30:05 -04:00
Sam Machin
a95a6d1683 clear main timout when interdigit timeout is started, (#1351)
* clear main timout when interdigit timeout is started,

also clear the asrTimeout when dtmf has taken priority

* lint

* lint
2025-09-12 09:01:51 -04:00
Dave Horton
65b3066866 catch exceptions from req.cancel() (#1359)
* catch exceptions from req.cancel()

* catch other instances of req.cancel

* fix prev commit
2025-09-11 12:25:36 -04:00
Hoan Luu Huu
057f52e56c speech_util v0.2.23 (#1358) 2025-09-11 01:30:31 -04:00
Hoan Luu Huu
b46be57eba singleDialer should create ConfirmCallSession with correct tmpFiles (#1357) 2025-09-10 22:37:41 -04:00
Hoan Luu Huu
f950d19d1c fix ConfirmCallSession in placeCall does not have access to tmpFiles for removing tmp file later (#1356) 2025-09-10 19:39:43 -04:00
pk32495
859132bb1c Fixed token missing log line. (#1354) 2025-09-10 15:03:56 -04:00
Dave Horton
acaadceaa2 fix exception when receiving REFER but dial task ended (#1353) 2025-09-10 12:23:32 -04:00
Hoan Luu Huu
add8d63e8e tts stream should not print speech credential (#1352) 2025-09-09 19:18:49 -04:00
Sam Machin
a05b72a420 Fix/1345 (#1349)
* don't try and guess carrier if LCR is set

* lint

* update dbHelpers dep
2025-09-06 14:15:16 -04:00
rammohan-y
28ff85225f Fixed issue for punctuation (#1344)
https://github.com/jambonz/jambonz-feature-server/issues/1343
2025-09-03 13:33:38 -04:00
Dave Horton
f2fe7c4d24 Fix/playback race by fs generates playback (#1331)
* update to speech-utils that generates playback id

* modify tts and say task to track current playback id and match against start and stop events

* bump speech utils

* wip

* wip

* fix race condition where say with playbackId gets stop event from previous play from cache file

* logging

* wip

* fix comparison when playing cached files

* logging
2025-08-26 09:39:25 -04:00
Dave Horton
97408c7d3b fix uncaught exception with llm streaming 2025-08-22 13:24:59 -04:00
Dave Horton
db5f0a0dce Feat/startup logging (#1333)
* turn down some logging

* add startup logging

* wip

* lint
2025-08-21 14:09:20 -04:00
Sam Machin
654ccd9d9d start timeout on bargein digits (#1312) 2025-08-19 08:34:33 -04:00
Dave Horton
ea27b20ac5 update speech-utils (#1324) 2025-08-17 10:09:55 -04:00
Hoan Luu Huu
96aa705378 update speech util verbsion (#1323) 2025-08-14 08:39:12 -04:00
rammohan-y
5e51849839 Sending synthesized-audio notification for servedFromCache as false (#1320)
* Sending synthesized-audio notification for servedFromCache as well
https://github.com/jambonz/jambonz-feature-server/issues/1319

* Sending back the id that was set, to track the synthesized-audio
e.g if we send a say verb having 100, it's synthesized-audio event will return 100 in the data to correleate the say verb and synthesized-audio event
2025-08-13 20:56:56 -04:00
Hoan Luu Huu
44f69fa76d Support resemble tts (#1322)
* support resemble tts

* update speech utils version
2025-08-13 08:15:29 -04:00
Dave Horton
73c77bea71 add support for deepgram entity_prompt 2025-08-11 20:58:14 -04:00
rammohan-y
babc0d0dbb Fix for issue https://github.com/jambonz/jambonz-feature-server/issues/1317. (#1318)
Unable to use mod_aws_transcribe module due to security error as sessionId is not populated
2025-08-11 09:17:32 -04:00
Hoan Luu Huu
6b8d0fe1a8 update db-helper 0.9.16 (#1316) 2025-08-08 10:43:59 -04:00
Dave Horton
66bb466297 fix bug where task.kill is not passed cs (#1315) 2025-08-07 16:01:45 -04:00
Dave Horton
1933f4ec0b Feat/freeswitch logging (#1309)
* include callSid on INVITEs to freeeswitch

* remove unnecessary warning
2025-08-04 09:19:47 -04:00
Sam Machin
b1089a1ae9 pass recogniser opts in amd to stt (#1308) 2025-08-01 22:26:51 -04:00
sathish kumar pasham
93e06d887e Fix security vulnerabilities by upgrading @jambonz/realtimedb-helpers (#1305) 2025-07-30 13:24:19 -04:00
Sam Machin
b478e0ecd2 fix assert, and force methods to upper case (#1304)
* fix assert, and force methods to upper case

* add alert for updateCall errors

* lint

* handle missing method
2025-07-30 08:32:15 -04:00
Sam Machin
94d43d4b70 use tmpFiles list of parent call-session (#1301)
fixes #1299
2025-07-29 22:08:00 -04:00
Hoan Luu Huu
eb449e9169 support deepgram river (#1273)
* support deepgram river

* wip

* rebase

* fix review comment

---------

Co-authored-by: Dave Horton <daveh@beachdognet.com>
2025-07-29 13:49:43 -04:00
Hoan Luu Huu
158d9d7d25 support stt latency metrics (#1252)
* support stt latency metrics

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* enable stt latency calculator by config verb

* wip

* wip

* wip

* fix jslint

* fixed gather timeout does not have latency calculation

* upadte verb specification to use notifySttLatency

* move stt latency metric from call session to stt-latency calculator

* wip
2025-07-29 09:56:37 -04:00
Hoan Luu Huu
5886d1d945 allow pause/resume background listen with silence/blank (#1300)
* allow pause/resume background listen with silence/blank

* wip

* wip

* wip

* wip

* update drachtio-fsmrf version
2025-07-28 07:58:30 -04:00
Hoan Luu Huu
352106ec0c support referTo with tel: prefix (#1297)
* support referTo with tel: prefix

* fix review comment
2025-07-23 10:52:10 -04:00
Sam Machin
05a6bf51a7 corrected typos (#1295)
🧙‍♀️
2025-07-21 07:22:51 -04:00
Sam Machin
bd1c763e72 urlencode the . in a url querystring on play (#1293)
* urlencode the . in a url querystring on play

* lint

* fix for non querystring urls too

* lint
2025-07-20 14:03:21 -04:00
Sam Machin
d831a4ca7f don't fetch if whisper is an object with a single verb in it (#1290)
* don't fetch if whisper is an object with a single verb in it

* disable URL fetching of verbs on whisper

* fixes for lint

* lint
2025-07-20 13:47:31 -04:00
Sam Machin
0cc6ea987f Fix/1277 (#1278)
* wip

* forwardPAI control in verb

* update deps

* package-loxck

* Update package-lock.json
2025-07-20 13:26:33 -04:00
rammohan-y
6e7521c91c Fix for issue where we should not delay from gather when JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS is set (#1283)
* Fix for issue https://github.com/jambonz/jambonz-feature-server/issues/1281

* Commented the reason for the change
2025-07-17 10:36:22 -04:00
Hoan Luu Huu
e0e2ade289 fixed gather cannot timeout if listenDuringPrompt is true (#1276)
* fixed gather cannot timeout if listenDuringPrompt is true

* wip

* wip

* wip
2025-07-15 22:53:22 -04:00
Dave Horton
ee895b4046 bump version 2025-07-15 11:40:14 -04:00
Dave Horton
2a42ccb0e1 fix for #1285 (#1286)
* fix for #1285

* typo
2025-07-15 11:38:48 -04:00
Dave Horton
62b6a814b7 fixes for LCC dial where a relative url is given as actionhook (#1282)
* fixes for LCC dial where a relative url is given as actionhook

* update to speech-utils 0.2.15 with configurable tmp folder location
2025-07-13 11:08:15 -04:00
Dave Horton
e415420150 Fix/cached audio race (#1279)
* fix for race condition when play-stop event from earlier command received

* wip

* say verb should not cache if disableTtsCache = true

* logging

* modify race condition logic to validate playback id in playback-stopped matches that from playback-start

* logging

---------

Co-authored-by: Quan HL <quan.luuhoang8@gmail.com>
2025-07-13 10:56:41 -04:00
Sam Machin
e6e039e0f2 add alert verb (#1270)
* add alert verb

* update dependencies

* Update package-lock.json

* remove await taskDone
2025-07-10 07:39:42 -04:00
Dave Horton
657e2d4a49 update speech-utils (#1275) 2025-07-09 16:10:08 -04:00
Hoan Luu Huu
337c1cded0 fixed transcription is not received when call is terminated (#1259)
* fixed transcription is not received when call is terminated

* wip

* fixed failing testcases

* wip

* wip

* wip

* wip

* should not do gracefulshutdown on stopAmd
2025-07-09 10:20:09 -04:00
Dave Horton
444abcb036 (fd 1056) if no payload returned from referHook in dial then dont do anything (#1271) 2025-07-07 12:50:46 -04:00
Hoan Luu Huu
c82a835e70 FD_1079: tts modules response_code = 0 should make say fail to exec. (#1269)
* FD_1079: tts modules response_code = 0 should make say fail to exec.

* fixed tts azure does not have response code

* fixed tts error does not raise alarm

* wip

* fixed
2025-07-07 07:59:29 -04:00
Hoan Luu Huu
3c185d4bd2 consistent assemblyAiOptions name (#1267)
* consistent assemblyAiOptions  name

* update verb specification version
2025-07-03 08:05:56 -04:00
Hoan Luu Huu
ba2049b705 support assemblyai v3 (#1265)
* support assemblyai v3

* wip

* wip

* wip

* wip

* wip

* wip
2025-07-01 15:46:19 -04:00
Dave Horton
7691af30de Fix/dial refer (#1264)
* Revert "Update dial.js (#1243)"

This reverts commit 259dedcded.

* add to .gitignore

* when we receive a REFER on the parent leg, after adulting the child the dial task in the parent session should end
2025-06-28 15:01:09 -04:00
Hoan Luu Huu
ab83b21979 support inworld tts (#1262)
* support inworld tts

* wip

* wip
2025-06-27 10:05:18 -04:00
Hoan Luu Huu
f18b62e165 support ultravox agent id (#1254)
* support ultravox agent id

* wip
2025-06-23 09:37:10 -04:00
Dave Horton
f98bf2a1f8 update to drachtio-fsmrf@4.0.4 (#1255) 2025-06-22 15:18:30 +02:00
Hoan Luu Huu
8c67c05d87 fixed bargin task loop forever (#1253) 2025-06-22 06:20:24 +02:00
Hoan Luu Huu
3f11ee58a7 fixed dub playOnTrack loop forever (#1247) 2025-06-18 21:37:32 +02:00
rammohan-y
c8d94026ff Removing video sdp when making an outbound call (#1242) 2025-06-18 21:31:00 +02:00
Hoan Luu Huu
5be6c54339 support mod_cartesia_transcribe (#1245) 2025-06-17 20:54:26 +02:00
Sam Machin
259dedcded Update dial.js (#1243) 2025-06-13 18:23:40 +02:00
Dave Horton
b70fea69cc Fix/dial unhandled rejection (#1239)
* fix bug with race condition in dial which could spike cpu due to unhandled exception

* update to 0.2.12 speech-utils with azure ssml fix

* minor
2025-06-11 11:40:51 +02:00
leedia-tech
2bea7e83e1 Update example-voicemail-greetings.json: included Italian (it-IT) voicemail response patterns based on common operator messages (#1226) 2025-06-11 11:03:51 +02:00
Rohan Sreerama
812076d4fe fix(create-call): fix url parsing (#1235) 2025-06-11 11:01:44 +02:00
Hoan Luu Huu
b0b74871e7 support say stream with text (#1227)
* support say stream with text

* wip

* wip

* wip

* wip

* update verb  specification
2025-06-10 16:56:44 +02:00
Hoan Luu Huu
29708a1f7c clear log from ws-requestor (#1238)
* clear log from ws-requestor

* wip

* wip
2025-06-10 10:34:33 +02:00
Rohan Sreerama
e686a11808 fix(play): fixes ep null issue (#1233)
* fix(play): fixes ep null issue

* chore(play): fixing formatting

* chore(play): update syntax

* chore(play): updates

* fix(play): updated fix

* fix(play): updated fix

---------

Co-authored-by: rsreerama3 <rsreerama3@gatech.edu>
2025-06-05 14:57:35 -04:00
Dave Horton
25f58d2e43 fixes #1230 (#1231) 2025-06-04 13:42:49 -04:00
Dave Horton
8e9ab83ca4 Fix/env vars rest dial (#1225)
* rest dial needs to support env vars

* wip
2025-05-30 10:14:44 -04:00
rammohan-y
e975511df5 Fix for play issue (#1223)
See https://github.com/jambonz/jambonz-feature-server/issues/1222
2025-05-29 10:32:03 -04:00
Hoan Luu Huu
4386df993c add retry for http/ws requestor (#1210)
* add retry for http requestor

* fix failing testcase

* wip

* update ws-requestor

* wip

* wip

* wip
2025-05-29 10:17:49 -04:00
Dave Horton
f20d3dba2f bump speech-utils 2025-05-28 13:13:16 -04:00
Dave Horton
b734952855 proper handling of case when amd fails because no speech credentials (#1220) 2025-05-28 13:01:08 -04:00
Hoan Luu Huu
4990b1fb68 fix mod dub throw undefined reference (#1209) 2025-05-28 09:58:33 -04:00
Hoan Luu Huu
3475f39b1d support deepgram model_id in speechCredential (#1213) 2025-05-28 08:00:56 -04:00
Hoan Luu Huu
690a7fcd55 add ws msgid to telemetry span if it's ws requestor (#1215)
* add ws msgid to telemetry span if it's ws requestor

* wip
2025-05-28 07:51:59 -04:00
Dave Horton
760394aa5e Revert "fix: member mute on conference (#1048)" (#1216)
This reverts commit 761b7f26e7.
2025-05-28 07:21:24 -04:00
Dave Horton
603dd482bc add a bit more info logging to gather (#1211) 2025-05-26 09:34:21 -04:00
Dave Horton
f670626cf7 google gemini set default model to models/gemini-2.0-flash-live-001 2025-05-22 08:12:58 -04:00
Hoan Luu Huu
b92a9c700e fixed google s2s mcp initiate wrong functionDeclarations (#1203)
* fixed google s2s mcp initiate wrong functionDeclarations

* populate model from llm verb to google setup message
2025-05-18 20:15:26 -04:00
Anton Voylenko
761b7f26e7 fix: member mute on conference (#1048) 2025-05-18 14:11:56 -04:00
Dave Horton
76df58bfc2 fix logging in start task msg (#1202)
* fix logging in start task msg

* generate uuids using native crypto lib
2025-05-16 16:54:25 -04:00
Dave Horton
c1cb57c3f6 update version 2025-05-14 15:38:15 -04:00
Dave Horton
610c9af274 update db-helpers 2025-05-13 10:32:30 -04:00
Dave Horton
c0a35af591 update to 0.2.10 speech-utils (#1199) 2025-05-13 10:11:26 -04:00
Hoan Luu Huu
9585018147 support whisper instructions (#1198)
* support whisper instructions

* wip

* update speech utils and verb specification
2025-05-13 09:44:00 -04:00
Hoan Luu Huu
d7884a837a update deepgram voice agent (#1191)
* update deepgram voice agent

* fix lint

* wip

* wip
2025-05-13 07:43:48 -04:00
Dave Horton
ca0bf36815 dont apply snake casing to either env vars or tool call args (#1194) (#1197) 2025-05-12 12:56:58 -04:00
Sam Machin
6b68d32e2c end if last_word_end is -1 (#1196)
* end if last_word_end is -1

* lint
2025-05-12 12:11:32 -04:00
rammohan-y
8217a76697 Removed this.name from Task constructor, as LLM's names are populated post calling the base construction (#1192)
Also fixed a jslint error
2025-05-12 09:14:33 -04:00
rammohan-y
5c8237b382 Feat 1179 race issue with play verb (#1183)
* Fixed race issue between queueCommand false and queueCommand true when play task is involved

https://github.com/jambonz/jambonz-feature-server/issues/1179

* removed unnecessary emitter

* added destroy mechanism for stickyEventEmitter

* clearing stickyEventEmitter

* memory leak fix
2025-05-11 20:25:48 -04:00
Vasudev Anubrolu
4ff5c845de feat/864 update speech utils for playht on prem (#1187)
* feat/864 update speech utils for playht on prem

* feat/864 update speech utils version package lock
2025-05-09 12:34:14 -04:00
Anton Voylenko
78ebd08490 feat: prioritize JAMBONES_LOGLEVEL over db setting (#1188) 2025-05-09 09:41:23 -04:00
Hoan Luu Huu
8b18532f31 fixed tts streaming buffer cannot reset timeoutwhen lastUpdateTime is short (#1184)
* fixed tts streaming buffer cannot reset timeoutwhen lastUpdateTime is short

* wip
2025-05-07 10:26:11 -04:00
rammohan-y
e4bb00b382 Send stop-playback event (#1186)
* Send stop-playback event

https://github.com/jambonz/jambonz-feature-server/issues/1185

* check if not notified in playback-stop, ensure that the stop-playback is sent when kill-playback is sent
2025-05-07 08:59:59 -04:00
Hoan Luu Huu
14295dcebc support google s2s (#1169)
* support google s2s

* wip
2025-05-07 07:20:33 -04:00
Hoan Luu Huu
4d68c179ea sip_decline release callSession if ws requestor is used (#1182) 2025-05-06 10:01:36 -04:00
Hoan Luu Huu
6205959f53 fix microsoft stt max client buffer size error for transcribe verb (#1173) 2025-04-29 09:41:24 -04:00
Hoan Luu Huu
ed92cb2632 update speech utils 0.2.7 (#1177)
* update speech utils 0.2.7

* wip
2025-04-29 08:26:09 -04:00
Sam Machin
3098e04ed6 send env_vars in callHook (#1175)
* send env_vars in callHook

* lint

* add try/catch
2025-04-28 09:51:37 -04:00
Hoan Luu Huu
7e2fe72b6c fix say verb cannot failover if tts_response-code != 2xx (#1174) 2025-04-28 08:46:08 -04:00
Hoan Luu Huu
c2666b7a09 fixed deepgram gather cannot be timeout on empty transcription with continueAsr (#1171) 2025-04-28 08:36:31 -04:00
Hoan Luu Huu
9d54ca8116 Jambonz support Model context protocol (MCP) (#1150)
* Jambonz support Model context protocol (MCP)

* merged mcp tools with existing llmOptions.tools

* support list of mcp servers

* wip

* wip

* wip

* fix voice agent

* fix open-ai

* fix review comment

* fix deepgram voice agent

* update verb specification version
2025-04-24 06:50:53 -04:00
Sam Machin
472f4f4532 clientTools over webhooks (#1167)
* clientTools over webhooks

* lint

* simpler toolHook response
2025-04-23 09:15:16 -04:00
Hoan Luu Huu
63899d0091 update speech utils version 0.2.6 (#1172) 2025-04-23 08:22:47 -04:00
rammohan-y
31e6997746 Updated drachtio-srf version (#1170)
https://github.com/drachtio/drachtio-server/issues/424
2025-04-22 11:06:59 -04:00
Hoan Luu Huu
15b583ef2c only connect to drachtio server if connected to freeswitch (#1123)
* only connect to drachtio server if connected to freeswitch

* wip

* wip
2025-04-22 09:55:39 -04:00
Sam Machin
0bf2013934 add default model (#1147) 2025-04-22 09:49:24 -04:00
rammohan-y
182c310191 remove video from sdp in case of reInvite if the call is audio call (#1159)
https://github.com/jambonz/jambonz-feature-server/issues/1158
2025-04-18 09:33:16 -04:00
Sam Machin
4e74bab728 handle errors in createing call (#1164)
* move createCall into the try/catch and add a completionReason to results for errors

* add default completionReason

fixes #1165

* lint
2025-04-17 07:43:22 -04:00
rammohan-y
87195b6444 Stop tts streaming, when bargeIn is enabled for gather verb and input is detected (#1154)
https://github.com/jambonz/jambonz-feature-server/issues/1153
2025-04-14 09:18:50 -04:00
rammohan-y
eb5e6fa515 Updated db-helper to 0.9.11 (#1152)
https://github.com/jambonz/jambonz-feature-server/issues/1151
2025-04-14 08:22:28 -04:00
Dave Horton
305facb03b Fix/11labs no client config (#1149)
* update to verb specs

* add parameter to api call when there is not client config provided
2025-04-12 10:36:35 -04:00
Dave Horton
d310ba0ed1 reduce verbosity of logging (#1145) 2025-04-09 15:36:58 -04:00
Hoan Luu Huu
77f0fc85a3 ell tts support speech and pronunciation_dictionary_locators (#1137) 2025-04-09 12:32:06 -04:00
Sam Machin
c708b7d007 fix initial message format (#1144) 2025-04-09 10:43:07 -04:00
Hoan Luu Huu
343b382373 fixed ws-requestor missing hook for dial:confirm (#1143) 2025-04-09 07:29:08 -04:00
rammohan-y
0a541e089d Fix for https://github.com/jambonz/jambonz-feature-server/issues/1138 (#1139) 2025-04-04 09:02:18 -04:00
rammohan-y
d910981b1a Allow hangup verb on siprec call (#1136)
* Allow hangup verb on siprec call
https://github.com/jambonz/jambonz-feature-server/issues/1135

* added sip:decline to AllowedSipRecVerbs
2025-04-04 08:23:39 -04:00
Hoan Luu Huu
3f2744f032 fixed replaceEndpoint offer single codec that callee does not support (#1131) 2025-04-03 07:58:39 -04:00
Dave Horton
fcaf2e59e7 initial changes for openai stt (#1127)
* initial changes for openai stt

* wip

* wip

* wip

* wip

* wip

* make minBargeinWordCount work for openai

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wipp

* wip

* wip

* wip

* openai stt: support for prompt templates

* lint

* wip

* support openai semantic_vad

* wip

* transcribe supports openai stt

* sip

* wip

* wip

* refactor list of stt vendors that dont need to be restarted after a final transcript

* cleanup

* wip

* cleanup

* wip

* wip

* wip

* remove credentials from log

* comment
2025-03-28 13:14:58 -04:00
rammohan-y
ee846b283d Feat 1120 video call - remove video media from SDP if the call is audio call (#1124)
* sending jambonz:error when the incoming message is not parsable

https://github.com/jambonz/jambonz-feature-server/issues/1094

* writing an alert when incoming paylod is invalid

* added content to the jambonz:error payload

* removing video media from sdp if the call is an audio call. This is to avoid sending video media to destination if the incoming call is an audio call

* calling removeVideoSdp only when the environment variable JAMBONES_VIDEO_CALLS_ENABLED_IN_FS is set to true, this will ensure there are no regression issues for audio calls

* fixed jslint errors
2025-03-28 12:56:08 -04:00
Hoan Luu Huu
acdb8695a0 allow cartesia model_id is override from synthesizer option (#1130) 2025-03-27 13:37:57 -04:00
Hoan Luu Huu
f33f197e8d gather say support ttstream (#1128) 2025-03-27 07:19:19 -04:00
Sam Machin
9c437ab687 use deepgramOptions.model (#1126)
* use deepgramOptions.model

* lint

* Update transcription-utils.js
2025-03-24 12:25:29 -04:00
Dave Horton
1873694784 update to dractio-fsmrf@4.0.2 2025-03-17 08:50:10 -04:00
73 changed files with 6809 additions and 1268 deletions

3
.gitignore vendored
View File

@@ -2,6 +2,9 @@
logs
*.log
.claude/
CLAUDE.md
# Runtime data
pids
*.pid

101
app.js
View File

@@ -27,8 +27,67 @@ const pino = require('pino');
const logger = pino(opts, pino.destination({sync: false}));
const {LifeCycleEvents, FS_UUID_SET_NAME, SystemState, FEATURE_SERVER} = require('./lib/utils/constants');
const installSrfLocals = require('./lib/utils/install-srf-locals');
installSrfLocals(srf, logger);
const createHttpListener = require('./lib/utils/http-listener');
const healthCheck = require('@jambonz/http-health-check');
const ProcessMonitor = require('./lib/utils/process-monitor');
const monitor = new ProcessMonitor(logger);
// Log startup
monitor.logStartup();
monitor.setupSignalHandlers();
logger.on('level-change', (lvl, _val, prevLvl, _prevVal, instance) => {
if (logger !== instance) {
return;
}
logger.info('system log level %s was changed to %s', prevLvl, lvl);
});
// Install the srf locals
installSrfLocals(srf, logger, {
onFreeswitchConnect: (wraper) => {
// Only connect to drachtio if freeswitch is connected
logger.info(`connected to freeswitch at ${wraper.ms.address}, start drachtio server`);
if (DRACHTIO_HOST) {
srf.connect({host: DRACHTIO_HOST, port: DRACHTIO_PORT, secret: DRACHTIO_SECRET });
srf.on('connect', (err, hp) => {
const arr = /^(.*)\/(.*)$/.exec(hp.split(',').pop());
srf.locals.localSipAddress = `${arr[2]}`;
logger.info(`connected to drachtio listening on ${hp}, local sip address is ${srf.locals.localSipAddress}`);
});
}
else {
logger.info(`listening for drachtio requests on port ${DRACHTIO_PORT}`);
srf.listen({port: DRACHTIO_PORT, secret: DRACHTIO_SECRET});
}
// Start Http server
createHttpListener(logger, srf)
.then(({server, app}) => {
httpServer = server;
healthCheck({app, logger, path: '/', fn: getCount});
return {server, app};
})
.catch((err) => {
logger.error(err, 'Error creating http listener');
});
},
onFreeswitchDisconnect: (wraper) => {
// check if all freeswitch connections are lost, disconnect drachtio server
logger.info(`lost connection to freeswitch at ${wraper.ms.address}`);
const ms = srf.locals.getFreeswitch();
if (!ms) {
logger.info('no freeswitch connections, stopping drachtio server');
disconnect();
}
}
});
if (NODE_ENV === 'test') {
srf.on('error', (err) => {
logger.info(err, 'Error connecting to drachtio');
});
}
// Init services
const writeSystemAlerts = srf.locals?.writeSystemAlerts;
if (writeSystemAlerts) {
writeSystemAlerts({
@@ -54,24 +113,6 @@ const {
const InboundCallSession = require('./lib/session/inbound-call-session');
const SipRecCallSession = require('./lib/session/siprec-call-session');
if (DRACHTIO_HOST) {
srf.connect({host: DRACHTIO_HOST, port: DRACHTIO_PORT, secret: DRACHTIO_SECRET });
srf.on('connect', (err, hp) => {
const arr = /^(.*)\/(.*)$/.exec(hp.split(',').pop());
srf.locals.localSipAddress = `${arr[2]}`;
logger.info(`connected to drachtio listening on ${hp}, local sip address is ${srf.locals.localSipAddress}`);
});
}
else {
logger.info(`listening for drachtio requests on port ${DRACHTIO_PORT}`);
srf.listen({port: DRACHTIO_PORT, secret: DRACHTIO_SECRET});
}
if (NODE_ENV === 'test') {
srf.on('error', (err) => {
logger.info(err, 'Error connecting to drachtio');
});
}
srf.use('invite', [
initLocals,
createRootSpan,
@@ -97,27 +138,20 @@ sessionTracker.on('idle', () => {
}
});
const getCount = () => sessionTracker.count;
const healthCheck = require('@jambonz/http-health-check');
let httpServer;
const createHttpListener = require('./lib/utils/http-listener');
createHttpListener(logger, srf)
.then(({server, app}) => {
httpServer = server;
healthCheck({app, logger, path: '/', fn: getCount});
return {server, app};
})
.catch((err) => {
logger.error(err, 'Error creating http listener');
});
const monInterval = setInterval(async() => {
srf.locals.stats.gauge('fs.sip.calls.count', sessionTracker.count);
try {
const systemInformation = await srf.locals.dbHelpers.lookupSystemInformation();
if (systemInformation && systemInformation.log_level) {
logger.level = systemInformation.log_level;
const envLogLevel = logger.levels.values[JAMBONES_LOGLEVEL.toLowerCase()];
const dbLogLevel = logger.levels.values[systemInformation.log_level];
const appliedLogLevel = Math.min(envLogLevel, dbLogLevel);
if (logger.levelVal !== appliedLogLevel) {
logger.level = logger.levels.labels[Math.min(envLogLevel, dbLogLevel)];
}
}
} catch (err) {
if (process.env.NODE_ENV === 'test') {
@@ -133,6 +167,7 @@ const disconnect = () => {
httpServer?.on('close', resolve);
httpServer?.close();
srf.disconnect();
srf.removeAllListeners();
srf.locals.mediaservers?.forEach((ms) => ms.disconnect());
});
};

View File

@@ -163,5 +163,72 @@
"wird sich bei Ihnen melden",
"ich melde mich bei dir",
"wir können nicht"
],
"it-IT": [
"segreteria telefonica",
"risponde la segreteria telefonica",
"lascia un messaggio",
"puoi lasciare un messaggio dopo il segnale",
"dopo il segnale acustico",
"il numero chiamato non è raggiungibile",
"non è raggiungibile",
"lascia pure un messaggio",
"puoi lasciare un messaggio"
],
"ja-JP": [
"この通話は留守番電話に転送されました",
"発信先は現在電話に出ることができません",
"発信音の後でメッセージを録音してください",
"録音を完了したら電話を切ることができます",
"只今電話に出ることができません",
"ただ今電話に出ることができません",
"ただいま電話に出ることができません",
"ピーという発信音の後にお名前とご用件をお話しください",
"ファックスを送られる方はスタートボタンを押してください",
"FAXを送られる方はスタートボタンを押してください",
"おかけになった電話をお呼びしましたが",
"お出になりません",
"おでになりません",
"お掛けになった電話番号は",
"おかけになった電話番号は",
"お掛けになった電話は",
"おかけになった電話は",
"現在使われておりません",
"番号をお確かめになって",
"お掛け直し下さい",
"おかけ直し下さい",
"おかけ直しください",
"こちらはNTTドコモです",
"こちらはエーユーです",
"こちらはソフトバンクです",
"電波の届かない",
"電源が入っていない",
"掛かりません",
"かかりません",
"お繋ぎすることが出来ません",
"お繋ぎ出来ません",
"お繋ぎすることができません",
"お繋ぎできません",
"おつなぎすることができません",
"おつなぎできません",
"メッセージを録音",
"留守番電話",
"お留守番サービス",
"留守番",
"留守電",
"留守",
"接続します",
"合図の音",
"ピーと",
"発信音",
"ご用件",
"伝言",
"お話しください",
"ファックス",
"FAX",
"終了",
"終了しました",
"終了いたしました",
"営業時間"
]
}

View File

@@ -130,7 +130,7 @@ const OPTIONS_PING_INTERVAL = parseInt(process.env.OPTIONS_PING_INTERVAL, 10) ||
const JAMBONZ_RECORD_WS_BASE_URL = process.env.JAMBONZ_RECORD_WS_BASE_URL || process.env.JAMBONES_RECORD_WS_BASE_URL;
const JAMBONZ_RECORD_WS_USERNAME = process.env.JAMBONZ_RECORD_WS_USERNAME || process.env.JAMBONES_RECORD_WS_USERNAME;
const JAMBONZ_RECORD_WS_PASSWORD = process.env.JAMBONZ_RECORD_WS_PASSWORD || process.env.JAMBONES_RECORD_WS_PASSWORD;
const JAMBONZ_DISABLE_DIAL_PAI_HEADER = process.env.JAMBONZ_DISABLE_DIAL_PAI_HEADER || false;
const JAMBONZ_DIAL_PAI_HEADER = process.env.JAMBONZ_DIAL_PAI_HEADER || false;
const JAMBONES_DISABLE_DIRECT_P2P_CALL = process.env.JAMBONES_DISABLE_DIRECT_P2P_CALL || false;
const JAMBONES_EAGERLY_PRE_CACHE_AUDIO = parseInt(process.env.JAMBONES_EAGERLY_PRE_CACHE_AUDIO, 10) || 0;
@@ -225,11 +225,11 @@ module.exports = {
JAMBONZ_RECORD_WS_BASE_URL,
JAMBONZ_RECORD_WS_USERNAME,
JAMBONZ_RECORD_WS_PASSWORD,
JAMBONZ_DISABLE_DIAL_PAI_HEADER,
JAMBONZ_DIAL_PAI_HEADER,
JAMBONES_DISABLE_DIRECT_P2P_CALL,
JAMBONES_USE_FREESWITCH_TIMER_FD,
JAMBONES_DIAL_SBC_FOR_REGISTERED_USER,
JAMBONES_MEDIA_TIMEOUT_MS,
JAMBONES_MEDIA_HOLD_TIMEOUT_MS,
JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS
JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS,
};

View File

@@ -3,7 +3,7 @@ const makeTask = require('../../tasks/make_task');
const RestCallSession = require('../../session/rest-call-session');
const CallInfo = require('../../session/call-info');
const {CallDirection, CallStatus} = require('../../utils/constants');
const uuidv4 = require('uuid-random');
const crypto = require('crypto');
const SipError = require('drachtio-srf').SipError;
const { validationResult, body } = require('express-validator');
const { validate } = require('@jambonz/verb-specifications');
@@ -12,10 +12,12 @@ const HttpRequestor = require('../../utils/http-requestor');
const WsRequestor = require('../../utils/ws-requestor');
const RootSpan = require('../../utils/call-tracer');
const dbUtils = require('../../utils/db-utils');
const { mergeSdpMedia, extractSdpMedia } = require('../../utils/sdp-utils');
const { decrypt } = require('../../utils/encrypt-decrypt');
const { mergeSdpMedia, extractSdpMedia, removeVideoSdp } = require('../../utils/sdp-utils');
const { createCallSchema, customSanitizeFunction } = require('../schemas/create-call');
const { selectHostPort } = require('../../utils/network');
const { JAMBONES_DIAL_SBC_FOR_REGISTERED_USER } = require('../../config');
const { createMediaEndpoint } = require('../../utils/media-endpoint');
const removeNullProperties = (obj) => (Object.keys(obj).forEach((key) => obj[key] === null && delete obj[key]), obj);
const removeNulls = (req, res, next) => {
@@ -66,7 +68,7 @@ router.post('/',
const {
lookupAppBySid
} = srf.locals.dbHelpers;
const {getSBC, getFreeswitch} = srf.locals;
const {getSBC} = srf.locals;
let sbcAddress = getSBC();
if (!sbcAddress) throw new Error('no available SBCs for outbound call creation');
const target = restDial.to;
@@ -80,7 +82,7 @@ router.post('/',
const {lookupTeamsByAccount, lookupAccountBySid} = srf.locals.dbHelpers;
const account = await lookupAccountBySid(req.body.account_sid);
const accountInfo = await lookupAccountDetails(req.body.account_sid);
const callSid = uuidv4();
const callSid = crypto.randomUUID();
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';
@@ -145,7 +147,7 @@ router.post('/',
// find handling sbc sip for called user
if (JAMBONES_DIAL_SBC_FOR_REGISTERED_USER && target.type === 'user') {
const { registrar } = srf.locals.dbHelpers;
const { registrar} = srf.locals.dbHelpers;
const reg = await registrar.query(target.name);
if (reg) {
sbcAddress = selectHostPort(logger, reg.sbcAddress, 'tcp')[1];
@@ -157,7 +159,9 @@ router.post('/',
* trunk isn't specified,
* check if from-number matches any existing numbers on Jambonz
* */
if (target.type === 'phone' && !target.trunk) {
const { lookupLcrByAccount} = srf.locals.dbHelpers;
const lcrs = await lookupLcrByAccount(req.body.account_sid);
if (target.type === 'phone' && !target.trunk && lcrs.length == 0) {
const str = restDial.from || '';
const callingNumber = str.startsWith('+') ? str.substring(1) : str;
const voip_carrier_sid = await lookupCarrierByPhoneNumber(req.body.account_sid, callingNumber);
@@ -169,9 +173,7 @@ router.post('/',
}
/* create endpoint for outdial */
const ms = getFreeswitch();
if (!ms) throw new Error('no available Freeswitch for outbound call creation');
const ep = await ms.createEndpoint();
const ep = await createMediaEndpoint(srf, logger);
logger.debug(`createCall: successfully allocated endpoint, sending INVITE to ${sbcAddress}`);
/* launch outdial */
@@ -180,10 +182,14 @@ router.post('/',
let localSdp = ep.local.sdp;
if (req.body.dual_streams) {
dualEp = await ms.createEndpoint();
dualEp = await createMediaEndpoint(srf, logger);
localSdp = mergeSdpMedia(localSdp, dualEp.local.sdp);
}
if (process.env.JAMBONES_VIDEO_CALLS_ENABLED_IN_FS) {
logger.debug('createCall: removing video sdp');
localSdp = removeVideoSdp(localSdp);
ep.modify(localSdp);
}
const connectStream = async(remoteSdp) => {
if (remoteSdp !== sdp) {
sdp = remoteSdp;
@@ -212,6 +218,13 @@ router.post('/',
* we merge the inbound call application,
* with the provided app params from the request body
*/
try {
if (application?.env_vars && Object.keys(application.env_vars).length > 0) {
restDial.env_vars = JSON.parse(decrypt(application.env_vars));
}
} catch (err) {
logger.info({err}, 'Unable to set env_vars');
}
const app = {
...application,
...req.body
@@ -224,9 +237,10 @@ router.post('/',
if ('WS' === app.call_hook?.method || /^wss?:/.test(app.call_hook.url)) {
logger.debug({call_hook: app.call_hook}, 'creating websocket for call hook');
app.requestor = new WsRequestor(logger, account.account_sid, app.call_hook, account.webhook_secret) ;
if (app.call_hook.url === app.call_status_hook.url || !app.call_status_hook?.url) {
if (app.call_hook.url === app.call_status_hook?.url || !app.call_status_hook?.url) {
logger.debug('reusing websocket for call status hook');
app.notifier = app.requestor;
app.call_status_hook = app.call_hook;
}
}
else {

View File

@@ -116,8 +116,8 @@ const customSanitizeFunction = (value) => {
/* trims characters at the beginning and at the end of a string */
value = value.trim();
/* Verify strings including 'http' via new URL */
if (value.includes('http')) {
// Only attempt to parse if the whole string is a URL
if (/^https?:\/\/\S+$/.test(value)) {
value = new URL(value).toString();
}
}

View File

@@ -1,4 +1,4 @@
const uuidv4 = require('uuid-random');
const crypto = require('crypto');
const {CallDirection, AllowedSipRecVerbs, WS_CLOSE_CODES} = require('./utils/constants');
const {parseSiprecPayload} = require('./utils/siprec-utils');
const CallInfo = require('./session/call-info');
@@ -15,6 +15,7 @@ const {
JAMBONES_DISABLE_DIRECT_P2P_CALL
} = require('./config');
const { createJambonzApp } = require('./dynamic-apps');
const { decrypt } = require('./utils/encrypt-decrypt');
module.exports = function(srf, logger) {
const {
@@ -45,7 +46,7 @@ module.exports = function(srf, logger) {
logger.info('getAccountDetails - rejecting call due to missing X-Account-Sid header');
return res.send(500);
}
const callSid = req.has('X-Retain-Call-Sid') ? req.get('X-Retain-Call-Sid') : uuidv4();
const callSid = req.has('X-Retain-Call-Sid') ? req.get('X-Retain-Call-Sid') : crypto.randomUUID();
const account_sid = req.get('X-Account-Sid');
req.locals = {callSid, account_sid, callId};
@@ -111,6 +112,14 @@ module.exports = function(srf, logger) {
req.locals.callingNumber = sipURIs[1];
}
}
// Feature server INVITE request pipelines taking time to finish,
// while connecting and fetch application from db and invoking webhook.
// call can be canceled without any handling, so we add a listener here
req.once('cancel', (sipMsg) => {
logger.info(`${callId} got CANCEL request`);
req.locals.canceled = true;
});
next();
}
@@ -331,7 +340,7 @@ module.exports = function(srf, logger) {
}
// Resolve application.speech_synthesis_voice if it's custom voice
if (app2.speech_synthesis_vendor === 'google' && app2.speech_synthesis_voice.startsWith('custom_')) {
if (app2.speech_synthesis_vendor === 'google' && app2.speech_synthesis_voice?.startsWith('custom_')) {
const arr = /custom_(.*)/.exec(app2.speech_synthesis_voice);
if (arr) {
const google_custom_voice_sid = arr[1];
@@ -348,11 +357,10 @@ module.exports = function(srf, logger) {
}
req.locals.application = app2;
// eslint-disable-next-line no-unused-vars
const {call_hook, call_status_hook, ...appInfo} = app; // mask sensitive data like user/pass on webhook
// eslint-disable-next-line no-unused-vars
const {requestor, notifier, ...loggable} = appInfo;
const {requestor, notifier, env_vars, ...loggable} = appInfo;
logger.info({app: loggable}, `retrieved application for incoming call to ${req.locals.calledNumber}`);
req.locals.callInfo = new CallInfo({
req,
@@ -362,13 +370,14 @@ module.exports = function(srf, logger) {
});
// if transferred call contains callInfo, let update original data to newly created callInfo in this instance.
if (app.transferredCall && app.callInfo) {
const {direction, callerName, from, to, originatingSipIp, originatingSipTrunkName} = app.callInfo;
const {direction, callerName, from, to, originatingSipIp, originatingSipTrunkName, customerData} = app.callInfo;
req.locals.callInfo.direction = direction;
req.locals.callInfo.callerName = callerName;
req.locals.callInfo.from = from;
req.locals.callInfo.to = to;
req.locals.callInfo.originatingSipIp = originatingSipIp;
req.locals.callInfo.originatingSipTrunkName = originatingSipTrunkName;
if (customerData) req.locals.callInfo.customerData = customerData;
delete app.callInfo;
}
next();
@@ -417,16 +426,28 @@ module.exports = function(srf, logger) {
...(app.fallback_speech_recognizer_language && {fallback_language: app.fallback_speech_recognizer_language})
}
};
let env_vars;
try {
if (app.env_vars) {
const d_env_vars = JSON.parse(decrypt(app.env_vars));
logger.info(`Setting env_vars: ${Object.keys(d_env_vars)}`); // Only log the keys not the values
env_vars = d_env_vars;
}
} catch (err) {
logger.info({err}, 'Unable to set env_vars');
}
const params = Object.assign(['POST', 'WS'].includes(app.call_hook.method) ? { sip: req.msg } : {},
req.locals.callInfo,
{ service_provider_sid: req.locals.service_provider_sid },
{ defaults });
{ defaults },
{ env_vars }
);
logger.debug({ params }, 'sending initial webhook');
const obj = rootSpan.startChildSpan('performAppWebhook');
span = obj.span;
const b3 = rootSpan.getTracingPropagation();
const httpHeaders = b3 && { b3 };
json = await app.requestor.request('session:new', app.call_hook, params, httpHeaders);
json = await app.requestor.request('session:new', app.call_hook, params, httpHeaders, span);
}
app.tasks = normalizeJambones(logger, json).map((tdata) => makeTask(logger, tdata));

View File

@@ -1,6 +1,6 @@
const {CallDirection, CallStatus} = require('../utils/constants');
const parseUri = require('drachtio-srf').parseUri;
const uuidv4 = require('uuid-random');
const crypto = require('crypto');
const {JAMBONES_API_BASE_URL} = require('../config');
/**
* @classdesc Represents the common information for all calls
@@ -57,7 +57,7 @@ class CallInfo {
// outbound call that is a child of an existing call
const {req, parentCallInfo, to, callSid} = opts;
srf = req.srf;
this.callSid = callSid || uuidv4();
this.callSid = callSid || crypto.randomUUID();
this.parentCallSid = parentCallInfo.callSid;
this.accountSid = parentCallInfo.accountSid;
this.applicationSid = parentCallInfo.applicationSid;

View File

@@ -10,8 +10,9 @@ const {
RecordState,
AllowedSipRecVerbs,
AllowedConfirmSessionVerbs,
TtsStreamingEvents
} = require('../utils/constants');
TtsStreamingEvents,
ListenStatus
} = require('../utils/constants.json');
const moment = require('moment');
const assert = require('assert');
const sessionTracker = require('./session-tracker');
@@ -23,15 +24,12 @@ const HttpRequestor = require('../utils/http-requestor');
const WsRequestor = require('../utils/ws-requestor');
const ActionHookDelayProcessor = require('../utils/action-hook-delay');
const TtsStreamingBuffer = require('../utils/tts-streaming-buffer');
const StickyEventEmitter = require('../utils/sticky-event-emitter');
const {parseUri} = require('drachtio-srf');
const {
JAMBONES_INJECT_CONTENT,
JAMBONES_EAGERLY_PRE_CACHE_AUDIO,
AWS_REGION,
JAMBONES_USE_FREESWITCH_TIMER_FD,
JAMBONES_MEDIA_TIMEOUT_MS,
JAMBONES_MEDIA_HOLD_TIMEOUT_MS,
JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS
} = require('../config');
const bent = require('bent');
const BackgroundTaskManager = require('../utils/background-task-manager');
@@ -39,7 +37,8 @@ const dbUtils = require('../utils/db-utils');
const BADPRECONDITIONS = 'preconditions not met';
const CALLER_CANCELLED_ERR_MSG = 'Response not sent due to unknown transaction';
const { NonFatalTaskError} = require('../utils/error');
const { sleepFor } = require('../utils/helpers');
const { createMediaEndpoint } = require('../utils/media-endpoint');
const SttLatencyCalculator = require('../utils/stt-latency-calculator');
const sqlRetrieveQueueEventHook = `SELECT * FROM webhooks
WHERE webhook_sid =
(
@@ -79,6 +78,11 @@ class CallSession extends Emitter {
this.callGone = false;
this.notifiedComplete = false;
this.rootSpan = rootSpan;
this.stickyEventEmitter = new StickyEventEmitter();
this.stickyEventEmitter.onSuccess = () => {
this.taskInProgress = null;
this.stickyEventEmitter.destroy();
};
this.backgroundTaskManager = new BackgroundTaskManager({
cs: this,
logger,
@@ -135,6 +139,39 @@ class CallSession extends Emitter {
this.requestor.on('handover', handover.bind(this));
this.requestor.on('reconnect-error', this._onSessionReconnectError.bind(this));
}
/**
* Currently this is used for openai STT, which has a prompt paramater and
* we have an experimental feature where you can send the conversation
* history in the prompt
*/
this.conversationTurns = [];
this.on('userSaid', this._onUserSaid.bind(this));
this.on('botSaid', this._onBotSaid.bind(this));
/**
* Support STT latency
*/
this.sttLatencyCalculator = new SttLatencyCalculator({
logger,
cs: this
});
this.on('transcribe-start', () => {
this.sttLatencyCalculator.resetTime();
});
this.on('on-transcription', () => {
this.sttLatencyCalculator.onTranscriptionReceived();
});
this.on('transcribe-stop', () => {
this.sttLatencyCalculator.onTranscribeStop();
});
}
get notifySttLatencyEnabled() {
return this._notifySttLatencyEnabled || false;
}
set notifySttLatencyEnabled(enabled) {
this._notifySttLatencyEnabled = enabled;
}
/**
@@ -205,6 +242,18 @@ class CallSession extends Emitter {
this._synthesizer = synth;
}
/**
* Say stream enabled
*/
get autoStreamTts() {
return this._autoStreamTts || false;
}
set autoStreamTts(i) {
this._autoStreamTts = i;
}
/**
* ASR TTS fallback
*/
@@ -609,6 +658,15 @@ class CallSession extends Emitter {
}
}
// disableTtsCache
get disableTtsCache() {
return this._disableTtsCache || false;
}
set disableTtsCache(d) {
this._disableTtsCache = d;
}
getTsStreamingVendor() {
let v;
if (this.currentTask?.isStreamingTts) {
@@ -661,7 +719,7 @@ class CallSession extends Emitter {
}
hasGlobalSttPunctuation() {
get hasGlobalSttPunctuation() {
return this._globalSttPunctuation !== undefined;
}
@@ -693,10 +751,25 @@ class CallSession extends Emitter {
return this._fillerNoise;
}
async pauseOrResumeBackgroundListenIfRequired(action, silence = false) {
if ((action == 'pauseCallRecording' || action == 'resumeCallRecording') &&
this.backgroundTaskManager.isTaskRunning('record')) {
this.logger.debug({action, silence}, 'CallSession:pauseOrResumeBackgroundListenIfRequired');
const backgroundListenTask = this.backgroundTaskManager.getTask('record');
const status = action === 'pauseCallRecording' ? ListenStatus.Pause : ListenStatus.Resume;
backgroundListenTask.updateListen(
status,
silence
);
}
}
async notifyRecordOptions(opts) {
const {action} = opts;
const {action, silence} = opts;
this.logger.debug({opts}, 'CallSession:notifyRecordOptions');
this.pauseOrResumeBackgroundListenIfRequired(action, silence);
/* if we have not answered yet, just save the details for later */
if (!this.dlg) {
if (action === 'startCallRecording') {
@@ -877,6 +950,12 @@ class CallSession extends Emitter {
this.ttsStreamingBuffer?.start();
}
stopTtsStream() {
this.requestor?.request('tts:streaming-event', '/streaming-event', {event_type: 'stream_closed'})
.catch((err) => this.logger.info({err}, 'CallSession:clearTtsStream - Error sending user_interruption'));
this.ttsStreamingBuffer?.stop();
}
async enableBotMode(gather, autoEnable) {
try {
let task;
@@ -953,8 +1032,6 @@ class CallSession extends Emitter {
(type === 'tts' && credential.use_for_tts) ||
(type === 'stt' && credential.use_for_stt)
)) {
this.logger.info(
`${type}: ${credential.vendor} ${credential.label ? `, label: ${credential.label}` : ''} `);
if ('google' === vendor) {
if (type === 'tts' && !credential.tts_tested_ok ||
type === 'stt' && !credential.stt_tested_ok) {
@@ -1018,11 +1095,19 @@ class CallSession extends Emitter {
return {
speech_credential_sid: credential.speech_credential_sid,
api_key: credential.api_key,
model_id: credential.model_id,
deepgram_stt_uri: credential.deepgram_stt_uri,
deepgram_tts_uri: credential.deepgram_tts_uri,
deepgram_stt_use_tls: credential.deepgram_stt_use_tls
};
}
else if ('gladia' === vendor) {
return {
speech_credential_sid: credential.speech_credential_sid,
api_key: credential.api_key,
region: credential.region,
};
}
else if ('soniox' === vendor) {
return {
speech_credential_sid: credential.speech_credential_sid,
@@ -1054,6 +1139,7 @@ class CallSession extends Emitter {
return {
api_key: credential.api_key,
model_id: credential.model_id,
api_uri: credential.api_uri,
options: credential.options
};
}
@@ -1069,6 +1155,7 @@ class CallSession extends Emitter {
return {
api_key: credential.api_key,
model_id: credential.model_id,
stt_model_id: credential.stt_model_id,
embedding: credential.embedding,
options: credential.options
};
@@ -1080,10 +1167,39 @@ class CallSession extends Emitter {
options: credential.options
};
}
else if ('resemble' === vendor) {
return {
api_key: credential.api_key,
resemble_tts_use_tls: credential.resemble_tts_use_tls,
resemble_tts_uri: credential.resemble_tts_uri,
};
}
else if ('inworld' === vendor) {
return {
api_key: credential.api_key,
model_id: credential.model_id,
options: credential.options
};
}
else if ('assemblyai' === vendor) {
return {
speech_credential_sid: credential.speech_credential_sid,
api_key: credential.api_key
api_key: credential.api_key,
service_version: credential.service_version
};
}
else if ('houndify' === vendor) {
return {
speech_credential_sid: credential.speech_credential_sid,
client_id: credential.client_id,
client_key: credential.client_key,
user_id: credential.user_id
};
}
else if ('deepgramflux' === vendor) {
return {
speech_credential_sid: credential.speech_credential_sid,
api_key: credential.api_key,
};
}
else if ('voxist' === vendor) {
@@ -1106,12 +1222,17 @@ class CallSession extends Emitter {
};
}
else if ('speechmatics' === vendor) {
this.logger.info({credential}, 'CallSession:getSpeechCredentials - speechmatics credential');
return {
api_key: credential.api_key,
speechmatics_stt_uri: credential.speechmatics_stt_uri,
};
}
else if ('openai' === vendor) {
return {
api_key: credential.api_key,
model_id: credential.model_id,
};
}
else if (vendor.startsWith('custom:')) {
return {
speech_credential_sid: credential.speech_credential_sid,
@@ -1157,6 +1278,7 @@ class CallSession extends Emitter {
this.ttsStreamingBuffer.on(TtsStreamingEvents.Pause, this._onTtsStreamingPause.bind(this));
this.ttsStreamingBuffer.on(TtsStreamingEvents.Resume, this._onTtsStreamingResume.bind(this));
this.ttsStreamingBuffer.on(TtsStreamingEvents.ConnectFailure, this._onTtsStreamingConnectFailure.bind(this));
this.ttsStreamingBuffer.on(TtsStreamingEvents.Connected, this._onTtsStreamingConnected.bind(this));
}
else {
this.logger.info(`CallSession:exec - not a normal call session: ${this.constructor.name}`);
@@ -1166,7 +1288,10 @@ class CallSession extends Emitter {
const taskNum = ++this.taskIdx;
const stackNum = this.stackIdx;
const task = this.tasks.shift();
this.logger.info(`CallSession:exec starting task #${stackNum}:${taskNum}: ${task.name}`);
this.isCurTaskPlay = TaskName.Play === task.name;
this.taskInProgress = task;
this.logger.info(
`CallSession:exec starting task #${stackNum}:${taskNum}: ${task.name} (task id: ${task.taskId})`);
this._notifyTaskStatus(task, {event: 'starting'});
// Register verbhook span wait for end
task.on('VerbHookSpanWaitForEnd', ({span}) => {
@@ -1240,7 +1365,7 @@ class CallSession extends Emitter {
this.logger.info('CallSession:exec all tasks complete');
this._stopping = true;
this._onTasksDone();
this._clearResources();
await this._clearResources();
if (!this.isConfirmCallSession && !this.isSmsCallSession) sessionTracker.remove(this.callSid);
@@ -1322,7 +1447,11 @@ class CallSession extends Emitter {
}
else {
if (this.req && !this.dlg) {
this.req.cancel();
try {
this.req.cancel();
} catch (err) {
this.logger.error({err}, 'CallSession:_lccCallStatus error cancelling request');
}
this._callReleased();
}
}
@@ -1385,6 +1514,14 @@ class CallSession extends Emitter {
method: 'POST'
};
}
/* if given a relative url then send to same base url as current session */
if (this.requestor._isRelativeUrl(opts.call_hook?.url)) {
opts.call_hook.url = `${this.requestor.baseUrl}${opts.call_hook?.url}`;
}
if (this.requestor._isRelativeUrl(opts.call_status_hook?.url)) {
opts.call_status_hook.url = `${this.requestor.baseUrl}${opts.call_status_hook?.url}`;
}
}
/**
@@ -1445,7 +1582,7 @@ class CallSession extends Emitter {
}
if (tasks) {
const t = normalizeJambones(this.logger, tasks).map((tdata) => makeTask(this.logger, tdata));
this.logger.info({tasks: listTaskNames(t)}, 'CallSession:_lccCallHook new task list');
this.logger.debug({tasks: listTaskNames(t)}, 'CallSession:_lccCallHook new task list');
this.replaceApplication(t);
if (this.wakeupResolver) {
//this.logger.debug({resolution}, 'CallSession:_onCommand - got commands, waking up..');
@@ -1612,8 +1749,8 @@ Duration=${duration} `
async _lccWhisper(opts, callSid) {
const {whisper} = opts;
let tasks;
const b3 = this.b3;
const httpHeaders = b3 && {b3};
//const b3 = this.b3;
//const httpHeaders = b3 && {b3};
// this whole thing requires us to be in a Dial verb
const task = this.currentTask;
@@ -1622,12 +1759,15 @@ Duration=${duration} `
}
// allow user to provide a url object, a url string, an array of tasks, or a single task
if (typeof whisper === 'string' || (typeof whisper === 'object' && whisper.url)) {
// retrieve a url
const json = await this.requestor(opts.call_hook, this.callInfo.toJSON(), httpHeaders);
tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
}
else if (Array.isArray(whisper)) {
// Disable passing a URL as not functional Sam Machin - 17/07/2025
//if (typeof whisper === 'string' || (typeof whisper === 'object' && whisper.url && !whisper.verb)) {
// // retrieve a url
// const json = await this.requestor(opts.call_hook, this.callInfo.toJSON(), httpHeaders);
// tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
//}
//else if (Array.isArray(whisper)) {
if (Array.isArray(whisper)) {
// an inline array of tasks
tasks = normalizeJambones(this.logger, whisper).map((tdata) => makeTask(this.logger, tdata));
}
@@ -1712,10 +1852,10 @@ Duration=${duration} `
this.currentTask.ep :
this.ep;
const db = parseDecibels(opts);
this.logger.info(`_lccBoostAudioSignal: boosting audio signal by ${db} dB`);
this.logger.debug(`_lccBoostAudioSignal: boosting audio signal by ${db} dB`);
const args = [ep.uuid, 'setGain', db];
const response = await ep.api('uuid_dub', args);
this.logger.info({response}, '_lccBoostAudioSignal: response from freeswitch');
this.logger.debug({response}, '_lccBoostAudioSignal: response from freeswitch');
}
async _lccMediaPath(desiredPath) {
@@ -1758,7 +1898,7 @@ Duration=${duration} `
return;
}
else if (tokens === undefined) {
this.logger.info({opts}, 'CallSession:_lccTtsTokens - invalid command since id is missing');
this.logger.info({opts}, 'CallSession:_lccTtsTokens - invalid command since tokens is missing');
return this.requestor.request('tts:tokens-result', '/tokens-result', {
id,
status: 'failed',
@@ -1768,7 +1908,6 @@ Duration=${duration} `
let res;
try {
res = await this.ttsStreamingBuffer?.bufferTokens(tokens);
this.logger.info({id, res}, 'CallSession:_lccTtsTokens - tts:tokens-result');
} catch (err) {
this.logger.info(err, 'CallSession:_lccTtsTokens');
}
@@ -1776,6 +1915,10 @@ Duration=${duration} `
.catch((err) => this.logger.debug({err}, 'CallSession:_notifyTaskStatus - Error sending'));
}
async _internalTtsStreamingBufferTokens(tokens) {
return await this.ttsStreamingBuffer?.bufferTokens(tokens) || {status: 'failed', reason: 'no tts streaming buffer'};
}
_lccTtsFlush(opts) {
this.ttsStreamingBuffer?.flush(opts);
}
@@ -1861,6 +2004,17 @@ Duration=${duration} `
}
} catch (err) {
this.logger.info({err, opts, callSid}, 'CallSession:updateCall - error updating call');
const {writeAlerts} = this.srf.locals;
try {
writeAlerts({
alert_type: 'error-updating-call',
account_sid: this.accountSid,
message: err.message,
target_sid: callSid
});
} catch (err) {
this.logger.error({err}, 'Error writing error-updating-call alert');
}
}
}
@@ -1903,10 +2057,14 @@ Duration=${duration} `
this.tasks = tasks;
this.taskIdx = 0;
this.stackIdx++;
this.logger.info({tasks: listTaskNames(tasks)},
this.logger.debug({tasks: listTaskNames(tasks)},
`CallSession:replaceApplication reset with ${tasks.length} new tasks, stack depth is ${this.stackIdx}`);
let curTaskKilled = false;
if (this.currentTask) {
this.logger.debug('CallSession:replaceApplication - killing current task ' +
this.currentTask?.name + ', taskId: ' + this.currentTask.taskId);
this.currentTask.kill(this, KillReason.Replaced);
curTaskKilled = true;
this.currentTask = null;
}
else if (this.wakeupResolver) {
@@ -1914,11 +2072,16 @@ Duration=${duration} `
this.wakeupResolver({reason: 'new tasks'});
this.wakeupResolver = null;
}
// if currentTask which is play, already got killed, no need to call uuid_break
if (!curTaskKilled && (!this.currentTask || this.currentTask === undefined) && this.isCurTaskPlay) {
this.logger.debug(`CallSession:replaceApplication - emitting uuid_break, taskId: ${this.taskInProgress?.taskId}`);
this.stickyEventEmitter.emit('uuid_break', this.taskInProgress);
}
}
kill(onBackgroundGatherBargein = false) {
if (this.isConfirmCallSession) this.logger.debug('CallSession:kill (ConfirmSession)');
else this.logger.info('CallSession:kill');
else this.logger.debug('CallSession:kill');
this._endVerbHookSpan();
if (this.currentTask) {
this.currentTask.kill(this);
@@ -1983,7 +2146,7 @@ Duration=${duration} `
task.synthesizer.label :
this.speechSynthesisLabel;
this.logger.info({vendor, language, voice, label},
this.logger.debug({vendor, language, voice, label},
'CallSession:_preCacheAudio - precaching audio for future prompt');
task._synthesizeWithSpecificVendor(this, this.ep, {vendor, language, voice, label, preCache: true})
.catch((err) => this.logger.error(err, 'CallSession:_preCacheAudio - error precaching audio'));
@@ -2053,7 +2216,7 @@ Duration=${duration} `
}
async _onCommand({msgid, command, call_sid, queueCommand, tool_call_id, data}) {
this.logger.info({msgid, command, queueCommand, data}, 'CallSession:_onCommand - received command');
this.logger.debug({msgid, command, queueCommand, data}, 'CallSession:_onCommand - received command');
let resolution;
switch (command) {
case 'redirect':
@@ -2062,18 +2225,18 @@ Duration=${duration} `
const t = normalizeJambones(this.logger, data)
.map((tdata) => makeTask(this.logger, tdata));
if (!queueCommand) {
this.logger.info({tasks: listTaskNames(t)}, 'CallSession:_onCommand new task list');
this.logger.debug({tasks: listTaskNames(t)}, 'CallSession:_onCommand new task list');
this.replaceApplication(t);
}
else if (JAMBONES_INJECT_CONTENT) {
if (JAMBONES_EAGERLY_PRE_CACHE_AUDIO) this._preCacheAudio(t);
this._injectTasks(t);
this.logger.info({tasks: listTaskNames(this.tasks)}, 'CallSession:_onCommand - updated task list');
this.logger.debug({tasks: listTaskNames(this.tasks)}, 'CallSession:_onCommand - updated task list');
}
else {
if (JAMBONES_EAGERLY_PRE_CACHE_AUDIO) this._preCacheAudio(t);
this.tasks.push(...t);
this.logger.info({tasks: listTaskNames(this.tasks)}, 'CallSession:_onCommand - updated task list');
this.logger.debug({tasks: listTaskNames(this.tasks)}, 'CallSession:_onCommand - updated task list');
}
resolution = {reason: 'received command, new tasks', queue: queueCommand, command};
resolution.command = listTaskNames(t);
@@ -2253,23 +2416,22 @@ Duration=${duration} `
// need to allocate an endpoint
try {
if (!this.ms) this.ms = this.getMS();
const ep = await this.ms.createEndpoint({
const ep = await this._createMediaEndpoint({
headers: {
'X-Jambones-Call-ID': this.callId,
'X-Call-Sid': this.callSid,
},
remoteSdp: this.req.body
});
//ep.cs = this;
this.ep = ep;
this.logger.info(`allocated endpoint ${ep.uuid}`);
this._configMsEndpoint();
this.ep.on('destroy', () => {
this.logger.debug(`endpoint was destroyed!! ${this.ep.uuid}`);
});
if (this.direction === CallDirection.Inbound) {
if (this.direction === CallDirection.Inbound || this.application?.transferredCall) {
if (task.earlyMedia && !this.req.finalResponseSent) {
this.res.send(183, {body: ep.local.sdp});
return {ep};
@@ -2334,16 +2496,34 @@ Duration=${duration} `
this.logger.error('CallSession:replaceEndpoint cannot be called without stable dlg');
return;
}
// When this call kicked out from conference, session need to replace endpoint
// but this.ms might be undefined/null at this case.
this.ms = this.ms || this.getMS();
// Destroy previous ep if it's still running.
if (this.ep?.connected) this.ep.destroy();
this.ep = await this.ms.createEndpoint({remoteSdp: this.dlg.remote.sdp});
this._configMsEndpoint();
/* Codec negotiation issue explanation:
*
* Problem scenario:
* 1. Initial negotiation:
* - FreeSWITCH → SBC: offers multiple codecs (PCMU, PCMA, G722)
* - SBC → Callee: passes all codecs (PCMU, PCMA, G722)
* - Callee → SBC: responds with PCMA (its supported codec)
* - SBC → FreeSWITCH: responds with PCMU (after transcoding)
*
* 2. After endpoint replacement:
* - If we only offer PCMU in the new endpoint
* - FreeSWITCH → SBC: offers only PCMU
* - SBC → Callee: offers only PCMU
* - Call fails: Callee rejects since it only supports PCMA
*
* Solution:
* Always have FreeSWITCH offer multiple codecs to the SBC, don't pass remote sdp here to ensure
* the SBC can reoffer the same codecs that the callee originally accepted.
* This prevents call failures during media renegotiation.
*/
await this.dlg.modify(this.ep.local.sdp);
this.ep = await this._createMediaEndpoint();
const sdp = await this.dlg.modify(this.ep.local.sdp);
await this.ep.modify(sdp);
this.logger.debug('CallSession:replaceEndpoint completed');
return this.ep;
}
@@ -2351,9 +2531,16 @@ Duration=${duration} `
/**
* Hang up the call and free the media endpoint
*/
_clearResources() {
async _clearResources() {
this.stickyEventEmitter.destroy();
this.stickyEventEmitter = null;
this.taskInProgress = null;
for (const resource of [this.dlg, this.ep, this.ep2]) {
if (resource && resource.connected) resource.destroy();
try {
if (resource && resource.connected) await resource.destroy();
} catch (err) {
this.logger.info({err}, 'CallSession:_clearResources - error clearing resources');
}
}
this.dlg = null;
this.ep = null;
@@ -2376,7 +2563,9 @@ Duration=${duration} `
this.backgroundTaskManager.stopAll();
this.clearOrRestoreActionHookDelayProcessor().catch((err) => {});
this.ttsStreamingBuffer?.stop();
this.stopTtsStream();
this.sttLatencyCalculator?.stop();
}
/**
@@ -2528,7 +2717,7 @@ Duration=${duration} `
*/
_onRefer(req, res) {
const task = this.currentTask;
const sd = task.sd;
const sd = task?.sd;
if (task && TaskName.Dial === task.name && sd && task.referHook) {
task.handleRefer(this, req, res);
}
@@ -2567,7 +2756,7 @@ Duration=${duration} `
if (json && Array.isArray(json)) {
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
if (tasks && tasks.length > 0) {
this.logger.info('CallSession:handleRefer received REFER, get new tasks');
this.logger.debug('CallSession:handleRefer received REFER, get new tasks');
this.replaceApplication(tasks);
if (this.wakeupResolver) {
this.wakeupResolver({reason: 'CallSession: referHook new taks'});
@@ -2593,15 +2782,8 @@ Duration=${duration} `
async createOrRetrieveEpAndMs() {
if (this.ms && this.ep) return {ms: this.ms, ep: this.ep};
// get a media server
if (!this.ms) {
const ms = this.srf.locals.getFreeswitch();
if (!ms) throw new Error('no available freeswitch');
this.ms = ms;
}
if (!this.ep) {
this.ep = await this.ms.createEndpoint({remoteSdp: this.req.body});
this._configMsEndpoint();
this.ep = await this._createMediaEndpoint({remoteSdp: this.req.body});
}
return {ms: this.ms, ep: this.ep};
}
@@ -2614,14 +2796,14 @@ Duration=${duration} `
if (typeof this.queueEventHookRequestor === 'undefined') {
const pp = this._pool.promise();
try {
this.logger.info({accountSid: this.accountSid}, 'performQueueWebhook: looking up account');
this.logger.debug({accountSid: this.accountSid}, 'performQueueWebhook: looking up account');
const [r] = await pp.query(sqlRetrieveQueueEventHook, [this.accountSid]);
if (0 === r.length) {
this.logger.info({accountSid: this.accountSid}, 'performQueueWebhook: no webhook provisioned');
this.queueEventHookRequestor = null;
}
else {
this.logger.info({accountSid: this.accountSid, webhook: r[0]}, 'performQueueWebhook: webhook found');
this.logger.debug({accountSid: this.accountSid, webhook: r[0]}, 'performQueueWebhook: webhook found');
this.queueEventHookRequestor = new HttpRequestor(this.logger, this.accountSid,
r[0], this.webhook_secret);
this.queueEventHook = r[0];
@@ -2635,7 +2817,7 @@ Duration=${duration} `
/* send webhook */
const params = {...obj, ...this.callInfo.toJSON()};
this.logger.info({accountSid: this.accountSid, params}, 'performQueueWebhook: sending webhook');
this.logger.debug({accountSid: this.accountSid, params}, 'performQueueWebhook: sending webhook');
this.queueEventHookRequestor.request('queue:status', this.queueEventHook, params)
.catch((err) => {
this.logger.info({err, accountSid: this.accountSid, obj}, 'Error sending queue notification event');
@@ -2754,8 +2936,7 @@ Duration=${duration} `
async reAnchorMedia(currentMediaRoute = MediaPath.PartialMedia) {
assert(this.dlg && this.dlg.connected && !this.ep);
this.ep = await this.ms.createEndpoint({remoteSdp: this.dlg.remote.sdp});
this._configMsEndpoint();
this.ep = await this._createMediaEndpoint({remoteSdp: this.dlg.remote.sdp});
await this.dlg.modify(this.ep.local.sdp, {
headers: {
'X-Reason': 'anchor-media'
@@ -2770,7 +2951,7 @@ Duration=${duration} `
async handleReinviteAfterMediaReleased(req, res) {
assert(this.dlg && this.dlg.connected && !this.ep);
const sdp = await this.dlg.modify(req.body);
this.logger.info({sdp}, 'CallSession:handleReinviteAfterMediaReleased - reinvite to A leg returned sdp');
this.logger.debug({sdp}, 'CallSession:handleReinviteAfterMediaReleased - reinvite to A leg returned sdp');
res.send(200, {body: sdp});
}
@@ -2830,59 +3011,13 @@ Duration=${duration} `
}
}
_configMsEndpoint() {
this._enableInbandDtmfIfRequired(this.ep);
this.ep.once('destroy', this._handleMediaTimeout.bind(this));
const opts = {
...(this.onHoldMusic && {holdMusic: `shout://${this.onHoldMusic.replace(/^https?:\/\//, '')}`}),
...(JAMBONES_USE_FREESWITCH_TIMER_FD && {timer_name: 'timerfd'}),
...(JAMBONES_MEDIA_TIMEOUT_MS && {media_timeout: JAMBONES_MEDIA_TIMEOUT_MS}),
...(JAMBONES_MEDIA_HOLD_TIMEOUT_MS && {media_hold_timeout: JAMBONES_MEDIA_HOLD_TIMEOUT_MS})
};
if (Object.keys(opts).length > 0) {
this.ep.set(opts);
}
const origDestroy = this.ep.destroy.bind(this.ep);
this.ep.destroy = async() => {
try {
if (this.currentTask?.name === TaskName.Transcribe && JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS) {
// transcribe task is being used, wait for some time before destroy
// if final transcription is received but endpoint is already closed,
// freeswitch module will not be able to send the transcription
this.logger.info('callSession:_configMsEndpoint -' +
' transcribe task, wait for some time before destroy');
await sleepFor(JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS);
}
await origDestroy();
} catch (err) {
this.logger.error(err, 'callSession:_configMsEndpoint - error destroying endpoint');
}
};
}
async _handleMediaTimeout(evt) {
if (evt.reason === 'MEDIA_TIMEOUT' && !this.callGone) {
if (evt?.reason === 'MEDIA_TIMEOUT' && !this.callGone) {
this.logger.info('CallSession:_handleMediaTimeout: received MEDIA_TIMEOUT, hangup the call');
this._jambonzHangup('Media Timeout');
}
}
async _enableInbandDtmfIfRequired(ep) {
if (ep.inbandDtmfEnabled) return;
// only enable inband dtmf detection if voip carrier dtmf_type === tones
if (this.inbandDtmfEnabled) {
// https://developer.signalwire.com/freeswitch/FreeSWITCH-Explained/Modules/mod-dptools/6587132/#0-about
try {
ep.execute('start_dtmf');
ep.inbandDtmfEnabled = true;
} catch (err) {
this.logger.info(err, 'CallSession:_enableInbandDtmf - error enable inband DTMF');
}
}
}
/**
* notifyTaskError - only used when websocket connection is used instead of webhooks
*/
@@ -2905,7 +3040,7 @@ Duration=${duration} `
_awaitCommandsOrHangup() {
assert(!this.wakeupResolver);
return new Promise((resolve, reject) => {
this.logger.info('_awaitCommandsOrHangup - waiting...');
this.logger.debug('_awaitCommandsOrHangup - waiting...');
this.wakeupResolver = resolve;
if (this._actionHookDelayProcessor) {
@@ -2925,7 +3060,7 @@ Duration=${duration} `
this.ep.play(this.fillerNoise.url);
this.ep.once('playback-start', (evt) => {
if (evt.file === this.fillerNoise.url && !this._isPlayingFillerNoise) {
this.logger.info('CallSession:_awaitCommandsOrHangup - filler noise started');
this.logger.debug('CallSession:_awaitCommandsOrHangup - filler noise started');
this.ep.api('uuid_break', this.ep.uuid)
.catch((err) => this.logger.info(err, 'Error killing filler noise'));
}
@@ -2934,9 +3069,21 @@ Duration=${duration} `
});
}
async _enableInbandDtmfIfRequired(ep) {
if (ep.inbandDtmfEnabled) return;
// only enable inband dtmf detection if voip carrier dtmf_type === tones
if (this.inbandDtmfEnabled) {
// https://developer.signalwire.com/freeswitch/FreeSWITCH-Explained/Modules/mod-dptools/6587132/#0-about
ep.execute('start_dtmf').catch((err) => {
this.logger.info({err}, 'CallSession:_enableInbandDtmfIfRequired - error starting DTMF');
});
ep.inbandDtmfEnabled = true;
}
}
_clearTasks(backgroundGather, evt) {
if (this.requestor instanceof WsRequestor && !backgroundGather.cleared) {
this.logger.info({evt}, 'CallSession:_clearTasks on event from background gather');
this.logger.debug({evt}, 'CallSession:_clearTasks on event from background gather');
try {
backgroundGather.cleared = true;
this.kill(true);
@@ -2963,10 +3110,20 @@ Duration=${duration} `
}
}
_onTtsStreamingConnected() {
this.requestor?.request('tts:streaming-event', '/streaming-event', {event_type: 'stream_open'})
.catch((err) => this.logger.info({err}, 'CallSession:_onTtsStreamingConnected - Error sending'));
}
_onTtsStreamingEmpty() {
const task = this.currentTask;
if (task && TaskName.Say === task.name) {
task.notifyTtsStreamIsEmpty();
} else if (
// If Gather nested say task is streaming
task && TaskName.Gather === task.name && task.sayTask && task.sayTask.isStreamingTts) {
const sayTask = task.sayTask;
sayTask.notifyTtsStreamIsEmpty();
}
}
@@ -3014,6 +3171,67 @@ Duration=${duration} `
this._jambonzHangup('Max Call Duration');
this._maxCallDurationTimer = null;
}
_onUserSaid(transcript) {
const count = this.conversationTurns.length;
if (count === 0 || this.conversationTurns[count - 1].type === 'assistant') {
this.conversationTurns.push({
type: 'user',
text: transcript
});
}
else {
this.conversationTurns[count - 1].text += ` ${transcript}`;
}
}
_onBotSaid(transcript) {
const count = this.conversationTurns.length;
if (count === 0 || this.conversationTurns[count - 1].type === 'user') {
this.conversationTurns.push({
type: 'assistant',
text: transcript
});
}
else {
this.conversationTurns[count - 1].text += ` ${transcript}`;
}
}
async _createMediaEndpoint(drachtioFsmrfOptions = {}) {
return await createMediaEndpoint(this.srf, this.logger, {
activeMs: this.getMS(),
drachtioFsmrfOptions,
onHoldMusic: this.onHoldMusic,
inbandDtmfEnabled: this.inbandDtmfEnabled,
mediaTimeoutHandler: this._handleMediaTimeout.bind(this),
});
}
getFormattedConversation(numTurns) {
const turns = this.conversationTurns.slice(-numTurns);
if (turns.length === 0) return null;
return turns.map((t) => {
if (t.type === 'user') {
return `user: ${t.text}`;
}
return `assistant: ${t.text}`;
}).join('\n');
}
startSttLatencyVad() {
if (this.notifySttLatencyEnabled) {
this.sttLatencyCalculator.start();
}
}
stopSttLatencyVad() {
this.sttLatencyCalculator.stop();
}
calculateSttLatency() {
return this.sttLatencyCalculator.calculateLatency();
}
}
module.exports = CallSession;

View File

@@ -8,7 +8,8 @@ const CallSession = require('./call-session');
*/
class ConfirmCallSession extends CallSession {
constructor({logger, application, dlg, ep, tasks, callInfo, accountInfo, memberId, confName, rootSpan, req}) {
// eslint-disable-next-line max-len
constructor({logger, application, dlg, ep, tasks, callInfo, accountInfo, memberId, confName, rootSpan, req, tmpFiles}) {
super({
logger,
application,
@@ -24,6 +25,7 @@ class ConfirmCallSession extends CallSession {
this.dlg = dlg;
this.ep = ep;
this.req = req;
this.tmpFiles = tmpFiles;
}
/**

View File

@@ -22,6 +22,12 @@ class InboundCallSession extends CallSession {
this.req = req;
this.res = res;
// if the call was canceled before we got here, handle it
if (this.req.locals.canceled) {
req.locals.logger.info('InboundCallSession: constructor - call was already canceled');
this._onCancel();
}
req.once('cancel', this._onCancel.bind(this));
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));

View File

@@ -27,10 +27,13 @@ class RestCallSession extends CallSession {
}
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
this._notifyCallStatusChange({
callStatus: CallStatus.Trying,
sipStatus: 100,
sipReason: 'Trying'
setImmediate(() => {
this._notifyCallStatusChange({
callStatus: CallStatus.Trying,
sipStatus: 100,
sipReason: 'Trying'
});
});
}
@@ -63,7 +66,7 @@ class RestCallSession extends CallSession {
this.callInfo.callTerminationBy = terminatedBy;
const duration = moment().diff(this.dlg.connectTime, 'seconds');
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
this.logger.debug(`RestCallSession: called party hung up by ${terminatedBy}`);
this.logger.info(`RestCallSession: called party hung up by ${terminatedBy}`);
this._callReleased();
}

View File

@@ -45,12 +45,11 @@ class SipRecCallSession extends InboundCallSession {
async answerSipRecCall() {
try {
this.ms = this.getMS();
let remoteSdp = this.sdp1.replace(/sendonly/, 'sendrecv');
this.ep = await this.ms.createEndpoint({remoteSdp});
this.ep = await this._createMediaEndpoint({remoteSdp});
//this.logger.debug({remoteSdp, localSdp: this.ep.local.sdp}, 'SipRecCallSession - allocated first endpoint');
remoteSdp = this.sdp2.replace(/sendonly/, 'sendrecv');
this.ep2 = await this.ms.createEndpoint({remoteSdp});
this.ep2 = await this._createMediaEndpoint({remoteSdp});
//this.logger.debug({remoteSdp, localSdp: this.ep2.local.sdp}, 'SipRecCallSession - allocated second endpoint');
await this.ep.bridge(this.ep2);
const combinedSdp = await createSipRecPayload(this.ep.local.sdp, this.ep2.local.sdp, this.logger);

31
lib/tasks/alert.js Normal file
View File

@@ -0,0 +1,31 @@
const Task = require('./task');
const {TaskName} = require('../utils/constants');
class TaskAlert extends Task {
constructor(logger, opts, parentTask) {
super(logger, opts);
this.message = this.data.message;
}
get name() { return TaskName.Alert; }
async exec(cs) {
const {srf, accountSid:account_sid, callSid:target_sid, applicationSid:application_sid} = cs;
const {writeAlerts, AlertType} = srf.locals;
await super.exec(cs);
writeAlerts({
account_sid,
alert_type: AlertType.APPLICATION,
detail: `Application SID ${application_sid}`,
message: this.message,
target_sid
}).catch((err) => this.logger.info({err}, 'Error generating alert application'));
}
async kill(cs) {
super.kill(cs);
this.notifyTaskDone();
}
}
module.exports = TaskAlert;

View File

@@ -49,7 +49,8 @@ class Conference extends Task {
this.confName = this.data.name;
[
'beep', 'startConferenceOnEnter', 'endConferenceOnExit', 'joinMuted',
'maxParticipants', 'waitHook', 'statusHook', 'endHook', 'enterHook', 'endConferenceDuration'
'maxParticipants', 'waitHook', 'statusHook', 'endHook', 'enterHook',
'endConferenceDuration', 'distributeDtmf'
].forEach((attr) => this[attr] = this.data[attr]);
this.record = this.data.record || {};
this.statusEvents = [];
@@ -356,6 +357,7 @@ class Conference extends Task {
//https://developer.signalwire.com/freeswitch/FreeSWITCH-Explained/Modules/mod_conference_3965534/
// mute | Enter conference muted
...((this.joinMuted || this.speakOnlyTo) && {mute: true}),
...(this.distributeDtmf && {'dist-dtmf': true})
}});
/**
@@ -673,7 +675,8 @@ class Conference extends Task {
confName: this.confName,
tasks,
rootSpan: cs.rootSpan,
req: cs.req
req: cs.req,
tmpFiles: cs.tmpFiles,
});
await this._playSession.exec();
this._playSession = null;

View File

@@ -17,12 +17,17 @@ class TaskConfig extends Task {
'actionHookDelayAction',
'boostAudioSignal',
'vad',
'ttsStream'
'ttsStream',
'autoStreamTts',
'disableTtsCache'
].forEach((k) => this[k] = this.data[k] || {});
if ('notifyEvents' in this.data) {
this.notifyEvents = !!this.data.notifyEvents;
}
if (this.hasNotifySttLatency) {
this.notifySttLatency = !!this.data.notifySttLatency;
}
if (this.bargeIn.enable) {
this.gatherOpts = {
@@ -82,7 +87,9 @@ class TaskConfig extends Task {
get hasVad() { return Object.keys(this.vad).length; }
get hasFillerNoise() { return Object.keys(this.fillerNoise).length; }
get hasReferHook() { return Object.keys(this.data).includes('referHook'); }
get hasNotifySttLatency() { return Object.keys(this.data).includes('notifySttLatency'); }
get hasTtsStream() { return Object.keys(this.ttsStream).length; }
get hasDisableTtsCache() { return Object.keys(this.data).includes('disableTtsCache'); }
get summary() {
const phrase = [];
@@ -111,12 +118,16 @@ class TaskConfig extends Task {
if (this.hasFillerNoise) phrase.push(`fillerNoise ${this.fillerNoise.enable ? 'on' : 'off'}`);
if (this.data.amd) phrase.push('enable amd');
if (this.notifyEvents) phrase.push(`event notification ${this.notifyEvents ? 'on' : 'off'}`);
if (this.hasNotifySttLatency) phrase.push(
`notifySttLatency ${this.notifySttLatency ? 'on' : 'off'}`);
if (this.onHoldMusic) phrase.push(`onHoldMusic: ${this.onHoldMusic}`);
if ('boostAudioSignal' in this.data) phrase.push(`setGain ${this.data.boostAudioSignal}`);
if (this.hasReferHook) phrase.push('set referHook');
if (this.hasTtsStream) {
phrase.push(`${this.ttsStream.enable ? 'enable' : 'disable'} ttsStream`);
}
if ('autoStreamTts' in this.data) phrase.push(`enable Say.stream value ${this.data.autoStreamTts ? 'on' : 'off'}`);
if (this.hasDisableTtsCache) phrase.push(`disableTtsCache ${this.data.disableTtsCache ? 'on' : 'off'}`);
return `${this.name}{${phrase.join(',')}}`;
}
@@ -128,6 +139,11 @@ class TaskConfig extends Task {
cs.notifyEvents = !!this.data.notifyEvents;
}
if (this.hasNotifySttLatency) {
this.logger.debug(`turning notifySttLatency ${this.notifySttLatency ? 'on' : 'off'}`);
cs.notifySttLatencyEnabled = this.notifySttLatency;
}
if (this.onHoldMusic) {
cs.onHoldMusic = this.onHoldMusic;
}
@@ -139,7 +155,7 @@ class TaskConfig extends Task {
try {
this.ep = ep;
this.startAmd(cs, ep, this, this.data.amd);
await this.startAmd(cs, ep, this, this.data.amd);
} catch (err) {
this.logger.info({err}, 'Config:exec - Error calling startAmd');
}
@@ -296,6 +312,11 @@ class TaskConfig extends Task {
});
}
if ('autoStreamTts' in this.data) {
this.logger.info(`Config: autoStreamTts set to ${this.data.autoStreamTts}`);
cs.autoStreamTts = this.data.autoStreamTts;
}
if (this.hasFillerNoise) {
const {enable, ...opts} = this.fillerNoise;
this.logger.info({fillerNoise: this.fillerNoise}, 'Config: fillerNoise');
@@ -311,7 +332,10 @@ class TaskConfig extends Task {
voiceMs: this.vad.voiceMs || 250,
silenceMs: this.vad.silenceMs || 150,
strategy: this.vad.strategy || 'one-shot',
mode: (this.vad.mode !== undefined && this.vad.mode !== null) ? this.vad.mode : 2
mode: (this.vad.mode !== undefined && this.vad.mode !== null) ? this.vad.mode : 2,
vendor: this.vad.vendor || 'silero',
threshold: this.vad.threshold || 0.5,
speechPadMs: this.vad.speechPadMs || 30,
};
}
@@ -330,10 +354,17 @@ class TaskConfig extends Task {
};
this.logger.info({opts: this.gatherOpts}, 'Config: enabling ttsStream');
cs.enableBackgroundTtsStream(this.sayOpts);
} else if (!this.ttsStream.enable) {
}
// only disable ttsStream if it specifically set to false
else if (this.ttsStream.enable === false) {
this.logger.info('Config: disabling ttsStream');
cs.disableTtsStream();
}
if (this.hasDisableTtsCache) {
this.logger.info(`set disableTtsCache = ${this.disableTtsCache}`);
cs.disableTtsCache = this.data.disableTtsCache;
}
}
async kill(cs) {

View File

@@ -19,7 +19,7 @@ const parseDecibels = require('../utils/parse-decibels');
const debug = require('debug')('jambonz:feature-server');
const {parseUri} = require('drachtio-srf');
const {ANCHOR_MEDIA_ALWAYS,
JAMBONZ_DISABLE_DIAL_PAI_HEADER,
JAMBONZ_DIAL_PAI_HEADER,
JAMBONES_DIAL_SBC_FOR_REGISTERED_USER} = require('../config');
const { isOnhold, isOpusFirst } = require('../utils/sdp-utils');
const { normalizeJambones } = require('@jambonz/verb-specifications');
@@ -109,6 +109,7 @@ class TaskDial extends Task {
this.tag = this.data.tag;
this.boostAudioSignal = this.data.boostAudioSignal;
this._mediaPath = MediaPath.FullMedia;
this.forwardPAI = this.data.forwardPAI;
if (this.dtmfHook) {
const {parentDtmfCollector, childDtmfCollector} = parseDtmfOptions(logger, this.data.dtmfCapture || {});
@@ -271,7 +272,12 @@ class TaskDial extends Task {
}
this._removeDtmfDetection(cs.dlg);
this._removeDtmfDetection(this.dlg);
await this._killOutdials();
try {
await this._killOutdials();
}
catch (err) {
this.logger.info({err}, 'Dial:kill - error killing outdials');
}
if (this.sd) {
const byeReasonHeader = this.killReason === KillReason.MediaTimeout ? 'Media Timeout' : undefined;
this.sd.kill(byeReasonHeader);
@@ -281,13 +287,22 @@ class TaskDial extends Task {
}
if (this.callSid) sessionTracker.remove(this.callSid);
if (this.listenTask) {
await this.listenTask.kill(cs);
this.listenTask.span.end();
try {
await this.listenTask.kill(cs);
this.listenTask?.span?.end();
}
catch (err) {
this.logger.error({err}, 'Dial:kill - error killing listen task');
}
this.listenTask = null;
}
if (this.transcribeTask) {
await this.transcribeTask.kill(cs);
this.transcribeTask.span.end();
try {
await this.transcribeTask.kill(cs);
this.transcribeTask?.span?.end();
} catch (err) {
this.logger.error({err}, 'Dial:kill - error killing transcribe task');
}
this.transcribeTask = null;
}
this.notifyTaskDone();
@@ -383,7 +398,11 @@ class TaskDial extends Task {
...customHeaders
}
}, httpHeaders);
if (json && Array.isArray(json)) {
res.send(202);
this.logger.info('DialTask:handleRefer - sent 202 Accepted');
const returnedInstructions = !!json && Array.isArray(json);
if (returnedInstructions) {
try {
const logger = isChild ? this.logger : this.sd.logger;
const tasks = normalizeJambones(logger, json).map((tdata) => makeTask(this.logger, tdata));
@@ -401,16 +420,21 @@ class TaskDial extends Task {
/* need to update the callSid of the child with its own (new) AdultingCallSession */
sessionTracker.add(adultingSession.callSid, adultingSession);
}
if (this.ep) this.ep.unbridge();
/* if we got the REFER on the parent leg, end the dial task after completing the refer */
if (!isChild) {
logger.info('DialTask:handleRefer - killing dial task after processing REFER on parent leg');
cs.currentTask?.kill(cs, KillReason.ReferComplete);
}
}
} catch (err) {
this.logger.info(err, 'Dial:handleRefer - error setting new application after receiving REFER');
}
}
//caller and callee legs are briged together, accept refer with 202 will release callee leg endpoint
//that makes freeswitch release endpoint for caller leg.
if (this.ep) this.ep.unbridge();
res.send(202);
this.logger.info('DialTask:handleRefer - sent 202 Accepted');
else {
this.logger.info('DialTask:handleRefer - no tasks returned from referHook, not setting new application');
}
} catch (err) {
this.logger.info({err}, 'DialTask:handleRefer - error processing incoming REFER');
res.send(err.statusCode || 501);
@@ -525,13 +549,14 @@ class TaskDial extends Task {
let sbcAddress = this.proxy || getSBC();
const teamsInfo = {};
let fqdn;
const forwardPAI = this.forwardPAI ?? JAMBONZ_DIAL_PAI_HEADER; // dial verb overides env var
this.logger.debug(forwardPAI, 'forwardPAI value');
if (!sbcAddress) throw new Error('no SBC found for outbound call');
this.headers = {
'X-Account-Sid': cs.accountSid,
...(req && req.has('X-CID') && {'X-CID': req.get('X-CID')}),
...(direction === 'outbound' && callInfo.sbcCallid && {'X-CID': callInfo.sbcCallid}),
...(!JAMBONZ_DISABLE_DIAL_PAI_HEADER && req && {
...(req && forwardPAI && {
...(req.has('P-Asserted-Identity') && {'P-Asserted-Identity': req.get('P-Asserted-Identity')}),
...(req.has('Privacy') && {'Privacy': req.get('Privacy')}),
}),
@@ -550,7 +575,8 @@ class TaskDial extends Task {
proxy: `sip:${sbcAddress}`,
callingNumber: this.callerId || fromUri.user,
...(this.callerName && {callingName: this.callerName}),
opusFirst: isOpusFirst(this.cs.ep.remote.sdp)
opusFirst: isOpusFirst(this.cs.ep.remote.sdp),
isVideoCall: this.cs.ep.remote.sdp.includes('m=video')
};
const t = this.target.find((t) => t.type === 'teams');
@@ -615,7 +641,9 @@ class TaskDial extends Task {
* trunk isn't specified,
* check if number matches any existing numbers
* */
if (t.type === 'phone' && !t.trunk) {
const { lookupLcrByAccount} = srf.locals.dbHelpers;
const lcrs = await lookupLcrByAccount(cs.accountSid);
if (t.type === 'phone' && !t.trunk && lcrs.length == 0) {
const str = this.callerId || req.callingNumber || '';
const callingNumber = str.startsWith('+') ? str.substring(1) : str;
const voip_carrier_sid = await lookupCarrierByPhoneNumber(cs.accountSid, callingNumber);
@@ -648,7 +676,8 @@ class TaskDial extends Task {
rootSpan: cs.rootSpan,
startSpan: this.startSpan.bind(this),
dialTask: this,
onHoldMusic: this.cs.onHoldMusic
onHoldMusic: this.cs.onHoldMusic,
tmpFiles: this.cs.tmpFiles,
});
this.dials.set(sd.callSid, sd);
@@ -749,6 +778,9 @@ class TaskDial extends Task {
this.epOther.api('uuid_break', this.epOther.uuid);
this.epOther.bridge(sd.ep);
}
else {
this.logger.error('Dial:_connectSingleDial - no other endpoint to bridge!');
}
this.bridged = true;
}
@@ -971,7 +1003,7 @@ class TaskDial extends Task {
this.epOther = null;
this._mediaPath = releaseEntirely ? MediaPath.NoMedia : MediaPath.PartialMedia;
this.logger.info(
`Dial:_releaseMedia - successfully released media from freewitch, media path is now ${this._mediaPath}`);
`Dial:_releaseMedia - successfully released media from freeswitch, media path is now ${this._mediaPath}`);
} catch (err) {
this.logger.info({err}, 'Dial:_releaseMedia error');
}
@@ -980,7 +1012,7 @@ class TaskDial extends Task {
async reAnchorMedia(cs, sd) {
if (cs.ep && sd.ep) return;
this.logger.info('Dial:reAnchorMedia - re-anchoring media to freewitch');
this.logger.info('Dial:reAnchorMedia - re-anchoring media to freeswitch');
await Promise.all([sd.reAnchorMedia(this._mediaPath), cs.reAnchorMedia(this._mediaPath)]);
this.epOther = cs.ep;
@@ -988,7 +1020,7 @@ class TaskDial extends Task {
this._mediaPath = MediaPath.FullMedia;
this.logger.info(
`Dial:_releaseMedia - successfully re-anchored media to freewitch, media path is now ${this._mediaPath}`);
`Dial:_releaseMedia - successfully re-anchored media to freeswitch, media path is now ${this._mediaPath}`);
}
// Handle RE-INVITE hold from caller leg.
@@ -1072,7 +1104,8 @@ class TaskDial extends Task {
accountInfo: this.cs.accountInfo,
tasks,
rootSpan: this.cs.rootSpan,
req: this.cs.req
req: this.cs.req,
tmpFiles: this.cs.tmpFiles,
});
await this._onHoldSession.exec();
this._onHoldSession = null;

View File

@@ -83,7 +83,8 @@ class TaskDub extends TtsTask {
action: 'playOnTrack',
track: this.track,
play: this.play,
loop: this.loop ? 'loop' : 'once',
// drachtio-fsmrf will convert loop from boolean to 'loop' or 'once'
loop: this.loop,
gain: this.gain
});
}

View File

@@ -370,7 +370,8 @@ class TaskEnqueue extends Task {
accountInfo: cs.accountInfo,
tasks: tasksToRun,
rootSpan: cs.rootSpan,
req: cs.req
req: cs.req,
tmpFiles: cs.tmpFiles,
});
await this._playSession.exec();
this._playSession = null;

View File

@@ -5,13 +5,18 @@ const {
AwsTranscriptionEvents,
AzureTranscriptionEvents,
DeepgramTranscriptionEvents,
GladiaTranscriptionEvents,
SonioxTranscriptionEvents,
CobaltTranscriptionEvents,
IbmTranscriptionEvents,
NvidiaTranscriptionEvents,
JambonzTranscriptionEvents,
AssemblyAiTranscriptionEvents,
HoundifyTranscriptionEvents,
DeepgramfluxTranscriptionEvents,
VoxistTranscriptionEvents,
CartesiaTranscriptionEvents,
OpenAITranscriptionEvents,
VadDetection,
VerbioTranscriptionEvents,
SpeechmaticsTranscriptionEvents
@@ -83,12 +88,15 @@ class TaskGather extends SttTask {
this._bufferedTranscripts = [];
this.partialTranscriptsCount = 0;
this.bugname_prefix = 'gather_';
}
get name() { return TaskName.Gather; }
get needsStt() { return this.input.includes('speech'); }
get isBackgroundGather() { return this.bugname_prefix === 'background_bargeIn_'; }
get wantsSingleUtterance() {
return this.data.recognizer?.singleUtterance === true;
}
@@ -110,6 +118,12 @@ class TaskGather extends SttTask {
return this.fillerNoise.startDelaySecs;
}
get isStreamingTts() { return this.sayTask && this.sayTask.isStreamingTts; }
getTtsVendorData() {
if (this.sayTask) return this.sayTask.getTtsVendorData(this.cs);
}
get summary() {
let s = `${this.name}{`;
if (this.input.length === 2) s += 'inputs=[speech,digits],';
@@ -130,7 +144,11 @@ class TaskGather extends SttTask {
try {
await this.handling(cs, obj);
} catch (error) {
if (error instanceof SpeechCredentialError) {
if (
// avoid bargein task with sticky will restart forever
// throw exception to stop the loop.
!this.sticky &&
error instanceof SpeechCredentialError) {
this.logger.info('Gather failed due to SpeechCredentialError, finished!');
this.notifyTaskDone();
return;
@@ -213,7 +231,9 @@ class TaskGather extends SttTask {
const startListening = async(cs, ep) => {
this._startTimer();
if (this.isContinuousAsr && 0 === this.timeout) this._startAsrTimer();
if (this.isContinuousAsr && 0 === this.timeout && !this.isBackgroundGather) {
this._startAsrTimer();
}
if (this.input.includes('speech') && !this.listenDuringPrompt) {
try {
await this._setSpeechHandlers(cs, ep);
@@ -233,6 +253,7 @@ class TaskGather extends SttTask {
const {span, ctx} = this.startChildSpan(`nested:${this.sayTask.summary}`);
const process = () => {
this.logger.debug('Gather: nested say task completed');
this.playComplete = true;
if (!this.listenDuringPrompt) {
startDtmfListener();
}
@@ -249,20 +270,28 @@ class TaskGather extends SttTask {
};
this.sayTask.span = span;
this.sayTask.ctx = ctx;
this.sayTask.exec(cs, {ep}) // kicked off, _not_ waiting for it to complete
this.sayTask
.exec(cs, {ep}) // kicked off, _not_ waiting for it to complete
.then(() => {
if (this.sayTask.isStreamingTts) return;
this.logger.debug('Gather:exec - nested say task completed');
span.end();
process();
return;
})
.catch((err) => {
process();
});
this.sayTask.on('playDone', (err) => {
span.end();
if (err) this.logger.error({err}, 'Gather:exec Error playing tts');
if (this.sayTask.isStreamingTts && !this.sayTask.closeOnStreamEmpty) {
// if streaming tts, we do not wait for it to complete if it is not closing the stream automatically
process();
});
}
}
else if (this.playTask) {
const {span, ctx} = this.startChildSpan(`nested:${this.playTask.summary}`);
const process = () => {
this.logger.debug('Gather: nested play task completed');
this.playComplete = true;
if (!this.listenDuringPrompt) {
startDtmfListener();
}
@@ -279,15 +308,17 @@ class TaskGather extends SttTask {
};
this.playTask.span = span;
this.playTask.ctx = ctx;
this.playTask.exec(cs, {ep}) // kicked off, _not_ waiting for it to complete
this.playTask
.exec(cs, {ep}) // kicked off, _not_ waiting for it to complete
.then(() => {
this.logger.debug('Gather:exec - nested play task completed');
span.end();
process();
return;
})
.catch((err) => {
process();
});
this.playTask.on('playDone', (err) => {
span.end();
if (err) this.logger.error({err}, 'Gather:exec Error playing url');
process();
});
}
else {
if (this.killed) {
@@ -331,6 +362,7 @@ class TaskGather extends SttTask {
this.sayTask?.span.end();
this._stopVad();
this._resolve('killed');
cs.stopSttLatencyVad();
}
updateTaskInProgress(opts) {
@@ -346,6 +378,9 @@ class TaskGather extends SttTask {
_onDtmf(cs, ep, evt) {
this.logger.debug(evt, 'TaskGather:_onDtmf');
if (!this._timeoutTimer && this.timeout > 0) {
this._startTimer();
}
clearTimeout(this.interDigitTimer);
let resolved = false;
if (this.dtmfBargein) {
@@ -370,12 +405,8 @@ class TaskGather extends SttTask {
if (this.digitBuffer.length === 0 && this.needsStt) {
// DTMF is higher priority than STT.
this.removeCustomEventListeners();
ep.stopTranscription({
vendor: this.vendor,
bugname: this.bugname,
})
.catch((err) => this.logger.error({err},
` Received DTMF, Error stopping transcription for vendor ${this.vendor}`));
this._clearAsrTimer(); //clear ASR timer as we're now using dtmf
this._stopTranscribing(ep);
}
this.digitBuffer += evt.dtmf;
const len = this.digitBuffer.length;
@@ -389,6 +420,7 @@ class TaskGather extends SttTask {
const ms = this.interDigitTimeout * 1000;
this.logger.debug(`starting interdigit timer of ${ms}`);
this.interDigitTimer = setTimeout(() => this._resolve('dtmf-interdigit-timeout'), ms);
this._clearTimer(); //clear main timer as we're now using interdigit dtmf timer
}
}
@@ -446,6 +478,28 @@ class TaskGather extends SttTask {
this.addCustomEventListener(ep, DeepgramTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
this.addCustomEventListener(ep, DeepgramTranscriptionEvents.ConnectFailure,
this._onVendorConnectFailure.bind(this, cs, ep));
this.addCustomEventListener(ep, DeepgramTranscriptionEvents.Error, this._onVendorError.bind(this, cs, ep));
break;
case 'deepgramflux':
this.bugname = `${this.bugname_prefix}deepgramflux_transcribe`;
this.addCustomEventListener(
ep, DeepgramfluxTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
this.addCustomEventListener(
ep, DeepgramfluxTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
this.addCustomEventListener(ep, DeepgramfluxTranscriptionEvents.ConnectFailure,
this._onVendorConnectFailure.bind(this, cs, ep));
this.addCustomEventListener(ep, DeepgramfluxTranscriptionEvents.Error, this._onVendorError.bind(this, cs, ep));
break;
case 'gladia':
this.bugname = `${this.bugname_prefix}gladia_transcribe`;
this.addCustomEventListener(
ep, GladiaTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
this.addCustomEventListener(ep, GladiaTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
this.addCustomEventListener(ep, GladiaTranscriptionEvents.ConnectFailure,
this._onVendorConnectFailure.bind(this, cs, ep));
this.addCustomEventListener(ep, GladiaTranscriptionEvents.Error, this._onVendorError.bind(this, cs, ep));
break;
case 'soniox':
@@ -525,6 +579,18 @@ class TaskGather extends SttTask {
this._onVendorConnectFailure.bind(this, cs, ep));
break;
case 'houndify':
this.bugname = `${this.bugname_prefix}houndify_transcribe`;
this.addCustomEventListener(ep, HoundifyTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep));
this.addCustomEventListener(ep, HoundifyTranscriptionEvents.Error,
this._onVendorError.bind(this, cs, ep));
this.addCustomEventListener(ep, HoundifyTranscriptionEvents.ConnectFailure,
this._onVendorConnectFailure.bind(this, cs, ep));
this.addCustomEventListener(ep, HoundifyTranscriptionEvents.Connect,
this._onVendorConnect.bind(this, cs, ep));
break;
case 'voxist':
this.bugname = `${this.bugname_prefix}voxist_transcribe`;
this.addCustomEventListener(ep, VoxistTranscriptionEvents.Transcription,
@@ -536,6 +602,17 @@ class TaskGather extends SttTask {
this._onVendorConnectFailure.bind(this, cs, ep));
break;
case 'cartesia':
this.bugname = `${this.bugname_prefix}cartesia_transcribe`;
this.addCustomEventListener(ep, CartesiaTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep));
this.addCustomEventListener(
ep, CartesiaTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
this.addCustomEventListener(ep, CartesiaTranscriptionEvents.Error, this._onVendorError.bind(this, cs, ep));
this.addCustomEventListener(ep, CartesiaTranscriptionEvents.ConnectFailure,
this._onVendorConnectFailure.bind(this, cs, ep));
break;
case 'speechmatics':
this.bugname = `${this.bugname_prefix}speechmatics_transcribe`;
this.addCustomEventListener(
@@ -553,6 +630,31 @@ class TaskGather extends SttTask {
break;
case 'openai':
this.bugname = `${this.bugname_prefix}openai_transcribe`;
this.addCustomEventListener(
ep, OpenAITranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
this.addCustomEventListener(
ep, OpenAITranscriptionEvents.SpeechStarted, this._onOpenAISpeechStarted.bind(this, cs, ep));
this.addCustomEventListener(
ep, OpenAITranscriptionEvents.SpeechStopped, this._onOpenAISpeechStopped.bind(this, cs, ep));
this.addCustomEventListener(ep, OpenAITranscriptionEvents.Connect,
this._onVendorConnect.bind(this, cs, ep));
this.addCustomEventListener(ep, OpenAITranscriptionEvents.ConnectFailure,
this._onVendorConnectFailure.bind(this, cs, ep));
this.addCustomEventListener(ep, OpenAITranscriptionEvents.Error,
this._onOpenAIErrror.bind(this, cs, ep));
/* openai delta transcripts are useful only for minBargeinWordCount eval */
if (this.minBargeinWordCount > 1) {
this.openaiPartials = [];
opts.OPENAI_WANT_PARTIALS = 1;
this.addCustomEventListener(
ep, OpenAITranscriptionEvents.PartialTranscript, this._onOpenAIPartialTranscript.bind(this, cs, ep));
}
this.modelSupportsConversationTracking = opts.OPENAI_MODEL !== 'whisper-1';
break;
default:
if (this.vendor.startsWith('custom:')) {
this.bugname = `${this.bugname_prefix}${this.vendor}_transcribe`;
@@ -577,13 +679,32 @@ class TaskGather extends SttTask {
}
_startTranscribing(ep) {
this.logger.debug({
this.logger.info({
vendor: this.vendor,
locale: this.language,
interim: this.interim,
bugname: this.bugname
}, 'Gather:_startTranscribing');
/* special feature for openai: we can provide a prompt that includes recent conversation history */
let prompt;
if (this.vendor === 'openai') {
if (this.modelSupportsConversationTracking) {
prompt = this.formatOpenAIPrompt(this.cs, {
prompt: this.data.recognizer?.openaiOptions?.prompt,
hintsTemplate: this.data.recognizer?.openaiOptions?.promptTemplates?.hintsTemplate,
// eslint-disable-next-line max-len
conversationHistoryTemplate: this.data.recognizer?.openaiOptions?.promptTemplates?.conversationHistoryTemplate,
hints: this.data.recognizer?.hints,
});
this.logger.debug({prompt}, 'Gather:_startTranscribing - created an openai prompt');
}
else if (this.data.recognizer?.hints?.length > 0) {
prompt = this.data.recognizer?.hints.join(', ');
}
}
/**
* Note: we don't need to ask deepgram for interim results, because they
* already send us words as they are finalized (is_final=true) even before
@@ -595,6 +716,7 @@ class TaskGather extends SttTask {
interim: this.interim,
bugname: this.bugname,
hostport: this.hostport,
prompt
}).catch((err) => {
const {writeAlerts, AlertType} = this.cs.srf.locals;
this.logger.error(err, 'TaskGather:_startTranscribing error');
@@ -606,14 +728,18 @@ class TaskGather extends SttTask {
target_sid: this.cs.callSid
});
}).catch((err) => this.logger.info({err}, 'Error generating alert for tts failure'));
// Some vendor use single connection, that we cannot use onConnect event to track transcription start
this.cs.emit('transcribe-start');
}
_startTimer() {
if (0 === this.timeout) return;
this.logger.debug(`Starting timoutTimer of ${this.timeout}ms`);
this._clearTimer();
this._timeoutTimer = setTimeout(() => {
// If continuousASR in use then extend by the asr window for more transcripts.
if (this.isContinuousAsr) this._startAsrTimer();
if (this.interDigitTimer) return; // let the inter-digit timer complete
else {
this._resolve(this.digitBuffer.length >= this.minDigits ? 'dtmf-num-digits' : 'timeout');
}
@@ -630,11 +756,13 @@ class TaskGather extends SttTask {
}
_startAsrTimer() {
if (this.vendor === 'deepgram') return; // no need
// Deepgram has a case that UtteranceEnd is not sent to cover the last word end time.
// So we need to wait for the asrTimeout to be sure that the last word is sent.
// if (this.vendor === 'deepgram') return; // no need
assert(this.isContinuousAsr);
this._clearAsrTimer();
this._asrTimer = setTimeout(() => {
this.logger.debug('_startAsrTimer - asr timer went off');
this.logger.info('_startAsrTimer - asr timer went off');
const evt = this.consolidateTranscripts(this._bufferedTranscripts, 1, this.language, this.vendor);
/* special case for speechmatics - keep listening if we dont have any transcripts */
@@ -645,7 +773,7 @@ class TaskGather extends SttTask {
}
this._resolve(this._bufferedTranscripts.length > 0 ? 'speech' : 'timeout', evt);
}, this.asrTimeout);
this.logger.debug(`_startAsrTimer: set for ${this.asrTimeout}ms`);
this.logger.info(`_startAsrTimer: set for ${this.asrTimeout}ms`);
}
_clearAsrTimer() {
@@ -657,7 +785,7 @@ class TaskGather extends SttTask {
}
_hangupCall() {
this.logger.debug('_hangupCall');
this.logger.debug('TaskGather:_hangupCall');
this.cs.hangup();
}
@@ -680,7 +808,7 @@ class TaskGather extends SttTask {
_startFinalAsrTimer() {
this._clearFinalAsrTimer();
this._finalAsrTimer = setTimeout(() => {
this.logger.debug('_startFinalAsrTimer - final asr timer went off');
this.logger.info('_startFinalAsrTimer - final asr timer went off');
const evt = this.consolidateTranscripts(this._bufferedTranscripts, 1, this.language, this.vendor);
this._resolve(this._bufferedTranscripts.length > 0 ? 'speech' : 'timeout', evt);
}, 1000);
@@ -758,12 +886,10 @@ class TaskGather extends SttTask {
return;
}
if (this.sayTask && !this.sayTask.killed) {
this.sayTask.removeAllListeners('playDone');
this.sayTask.kill(cs);
this.sayTask = null;
}
if (this.playTask && !this.playTask.killed) {
this.playTask.removeAllListeners('playDone');
this.playTask.kill(cs);
this.playTask = null;
}
@@ -771,26 +897,39 @@ class TaskGather extends SttTask {
}
_onTranscription(cs, ep, evt, fsEvent) {
// check if we are in graceful shutdown mode
if (ep.gracefulShutdownResolver) {
ep.gracefulShutdownResolver();
}
// make sure this is not a transcript from answering machine detection
const bugname = fsEvent.getHeader('media-bugname');
const finished = fsEvent.getHeader('transcription-session-finished');
this.logger.debug({evt, bugname, finished, vendor: this.vendor}, 'Gather:_onTranscription raw transcript');
if (bugname && this.bugname !== bugname) return;
if (bugname && this.bugname !== bugname) {
this.logger.info(
`Gather:_onTranscription - ignoring transcript from ${bugname} because our bug is ${this.bugname}`);
return;
}
if (finished === 'true') return;
if (this.vendor === 'ibm' && evt?.state === 'listening') return;
// emit an event to the call session to track the time transcription is received
cs.emit('on-transcription');
if (this.vendor === 'deepgram' && evt.type === 'UtteranceEnd') {
/* we will only get this when we have set utterance_end_ms */
if (this._bufferedTranscripts.length === 0) {
this.logger.debug('Gather:_onTranscription - got UtteranceEnd event from deepgram but no buffered transcripts');
this.logger.info('Gather:_onTranscription - got UtteranceEnd event from deepgram but no buffered transcripts');
}
else {
const utteranceTime = evt.last_word_end;
if (utteranceTime && this._dgTimeOfLastUnprocessedWord && utteranceTime < this._dgTimeOfLastUnprocessedWord) {
this.logger.debug('Gather:_onTranscription - got UtteranceEnd with unprocessed words, continue listening');
// eslint-disable-next-line max-len
if (utteranceTime && this._dgTimeOfLastUnprocessedWord && utteranceTime < this._dgTimeOfLastUnprocessedWord && utteranceTime != -1) {
this.logger.info('Gather:_onTranscription - got UtteranceEnd with unprocessed words, continue listening');
}
else {
this.logger.debug('Gather:_onTranscription - got UtteranceEnd from deepgram, return buffered transcript');
this.logger.info('Gather:_onTranscription - got UtteranceEnd from deepgram, return buffered transcript');
evt = this.consolidateTranscripts(this._bufferedTranscripts, 1, this.language, this.vendor);
this._bufferedTranscripts = [];
this._resolve('speech', evt);
@@ -805,7 +944,7 @@ class TaskGather extends SttTask {
evt = this.normalizeTranscription(evt, this.vendor, 1, this.language,
this.shortUtterance, this.data.recognizer.punctuation);
//this.logger.debug({evt, bugname, finished, vendor: this.vendor}, 'Gather:_onTranscription normalized transcript');
this.logger.debug({evt, bugname, finished, vendor: this.vendor}, 'Gather:_onTranscription normalized transcript');
if (evt.alternatives.length === 0) {
this.logger.info({evt}, 'TaskGather:_onTranscription - got empty transcript, continue listening');
@@ -861,6 +1000,12 @@ class TaskGather extends SttTask {
}
}
// receive a final transcript, calculate the stt latency for this transcript
const sttLatency = this.cs.calculateSttLatency();
if (!emptyTranscript && sttLatency) {
this.stt_latency_ms += `${sttLatency.stt_latency_ms},`;
}
if (this.isContinuousAsr) {
/* append the transcript and start listening again for asrTimeout */
const t = evt.alternatives[0].transcript;
@@ -965,6 +1110,11 @@ class TaskGather extends SttTask {
this.cs.requestor.request('verb:hook', this.partialResultHook, Object.assign({speech: evt},
this.cs.callInfo, httpHeaders));
}
else if (this.vendor === 'deepgramflux' &&
['EagerEndOfTurn', 'TurnResumed'].includes(evt.vendor.evt?.event)) {
this.logger.debug(`Gather:_onTranscription - deepgramflux event detected: ${evt.event}`);
this.performAction({speech: evt, reason: 'speechDetected'}, false);
}
if (this.vendor === 'soniox') {
if (evt.vendor.finalWords.length) {
this.logger.debug({evt}, 'TaskGather:_onTranscription - buffering soniox transcript');
@@ -1012,11 +1162,7 @@ class TaskGather extends SttTask {
async _startFallback(cs, ep, evt) {
if (this.canFallback) {
ep.stopTranscription({
vendor: this.vendor,
bugname: this.bugname
})
.catch((err) => this.logger.error({err}, `Error stopping transcription for primary vendor ${this.vendor}`));
this._stopTranscribing(ep);
try {
this.logger.debug('gather:_startFallback');
this.notifyError({ msg: 'ASR error',
@@ -1078,6 +1224,33 @@ class TaskGather extends SttTask {
this._onVendorError(cs, _ep, {error: JSON.stringify(e)});
}
async _onOpenAIErrror(cs, _ep, evt) {
// eslint-disable-next-line no-unused-vars
const {message, ...e} = evt;
this._onVendorError(cs, _ep, {error: JSON.stringify(e)});
}
async _onOpenAISpeechStarted(cs, _ep, evt) {
this.logger.debug({evt}, 'TaskGather:_onOpenAISpeechStarted');
}
async _onOpenAISpeechStopped(cs, _ep, evt) {
this.logger.debug({evt}, 'TaskGather:_onOpenAISpeechStopped');
}
async _onOpenAIPartialTranscript(cs, _ep, evt) {
if (!this.playComplete) {
const words = evt.delta.split(' ').filter((w) => /[A-Za-z0-0]/.test(w));
this.openaiPartials.push(...words);
this.logger.debug({words, partials: this.openaiPartials, evt}, 'TaskGather:_onOpenAIPartialTranscript - words');
if (this.openaiPartials.length >= this.minBargeinWordCount) {
this.logger.debug({partials: this.openaiPartials}, 'killing audio due to speech (openai)');
this._killAudio(cs);
this.notifyStatus({event: 'speech-bargein-detected', words: this.openaiPartials});
}
}
}
async _onVendorError(cs, _ep, evt) {
super._onVendorError(cs, _ep, evt);
if (!(await this._startFallback(cs, _ep, evt))) {
@@ -1118,17 +1291,26 @@ class TaskGather extends SttTask {
}
}
async _stopTranscribing(ep) {
// Fix for https://github.com/jambonz/jambonz-feature-server/issues/1281
// We should immediately call stop transcription from gather
// so that next gather can start transcription immediately
ep.stopTranscription({
vendor: this.vendor,
bugname: this.bugname,
gracefulShutdown: false
})
.catch((err) => {
if (this.resolved) return;
this.logger.error({err}, 'Error stopping transcription');
});
this.cs.emit('transcribe-stop');
}
async _resolve(reason, evt) {
this.logger.debug(`TaskGather:resolve with reason ${reason}`);
this.logger.info({evt}, `TaskGather:resolve with reason ${reason}`);
if (this.needsStt && this.ep && this.ep.connected) {
this.ep.stopTranscription({
vendor: this.vendor,
bugname: this.bugname
})
.catch((err) => {
if (this.resolved) return;
this.logger.error({err}, 'Error stopping transcription');
});
this._stopTranscribing(this.ep);
}
if (this.resolved) {
this.logger.debug('TaskGather:_resolve - already resolved');
@@ -1147,15 +1329,32 @@ class TaskGather extends SttTask {
this._clearAsrTimer();
this._clearFinalAsrTimer();
let sttLatencyMetrics = {};
if (this.needsStt) {
const sttLatency = this.cs.calculateSttLatency();
if (sttLatency) {
this.stt_latency_ms = this.stt_latency_ms.endsWith(',') ?
this.stt_latency_ms.slice(0, -1) : this.stt_latency_ms;
sttLatencyMetrics = {
'stt.latency_ms': this.stt_latency_ms,
'stt.talkspurts': JSON.stringify(sttLatency.talkspurts),
'stt.start_time': sttLatency.stt_start_time,
'stt.stop_time': sttLatency.stt_stop_time,
'stt.usage': sttLatency.stt_usage,
};
}
}
this.span.setAttributes({
channel: 1,
'stt.label': this.label || 'None',
'stt.resolve': reason,
'stt.result': JSON.stringify(evt)
'stt.result': JSON.stringify(evt),
...sttLatencyMetrics
});
if (this.callSession && this.callSession.callGone) {
this.logger.debug('TaskGather:_resolve - call is gone, not invoking web callback');
this.logger.info('TaskGather:_resolve - call is gone, not invoking web callback');
this.notifyTaskDone();
return;
}
@@ -1179,6 +1378,9 @@ class TaskGather extends SttTask {
let returnedVerbs = false;
try {
const latencies = Object.fromEntries(
Object.entries(sttLatencyMetrics).map(([key, value]) => [key.replace('stt.', 'stt_'), value])
);
if (reason.startsWith('dtmf')) {
if (this.parentTask) this.parentTask.emit('dtmf', evt);
else {
@@ -1187,11 +1389,12 @@ class TaskGather extends SttTask {
}
}
else if (reason.startsWith('speech')) {
this.cs.emit('userSaid', evt.alternatives[0].transcript);
if (this.parentTask) this.parentTask.emit('transcription', evt);
else {
this.emit('transcription', evt);
this.logger.debug('TaskGather:_resolve - invoking performAction');
returnedVerbs = await this.performAction({speech: evt, reason: 'speechDetected'});
returnedVerbs = await this.performAction({speech: evt, reason: 'speechDetected', ...latencies});
this.logger.debug({returnedVerbs}, 'TaskGather:_resolve - back from performAction');
}
}
@@ -1199,20 +1402,20 @@ class TaskGather extends SttTask {
if (this.parentTask) this.parentTask.emit('timeout', evt);
else {
this.emit('timeout', evt);
returnedVerbs = await this.performAction({reason: 'timeout'});
returnedVerbs = await this.performAction({reason: 'timeout', ...latencies});
}
}
else if (reason.startsWith('stt-error')) {
if (this.parentTask) this.parentTask.emit('stt-error', evt);
else {
this.emit('stt-error', evt);
returnedVerbs = await this.performAction({reason: 'error', details: evt.error});
returnedVerbs = await this.performAction({reason: 'error', details: evt.error, ...latencies});
}
} else if (reason.startsWith('stt-low-confidence')) {
if (this.parentTask) this.parentTask.emit('stt-low-confidence', evt);
else {
this.emit('stt-low-confidence', evt);
returnedVerbs = await this.performAction({speech:evt, reason: 'stt-low-confidence'});
returnedVerbs = await this.performAction({speech:evt, reason: 'stt-low-confidence', ...latencies});
}
}
} catch (err) { /*already logged error*/ }

View File

@@ -1,10 +1,21 @@
const Task = require('./task');
const {TaskName, TaskPreconditions, ListenEvents, ListenStatus} = require('../utils/constants');
const {TaskName, TaskPreconditions, ListenEvents, ListenStatus} = require('../utils/constants.json');
const makeTask = require('./make_task');
const moment = require('moment');
const MAX_PLAY_AUDIO_QUEUE_SIZE = 10;
const DTMF_SPAN_NAME = 'dtmf';
function escapeString(str) {
return str
.replace(/\\/g, '\\\\') // Escape backslashes
.replace(/"/g, '\\"') // Escape double quotes
.replace(/[\b]/g, '\\b') // Escape backspace (NOTE: [\b] not \b)
.replace(/\f/g, '\\f') // Escape formfeed
.replace(/\n/g, '\\n') // Escape newlines
.replace(/\r/g, '\\r') // Escape carriage returns
.replace(/\t/g, '\\t'); // Escape tabs
}
class TaskListen extends Task {
constructor(logger, opts, parentTask) {
super(logger, opts);
@@ -16,10 +27,21 @@ class TaskListen extends Task {
this.preconditions = TaskPreconditions.Endpoint;
[
'action', 'auth', 'method', 'url', 'finishOnKey', 'maxLength', 'metadata', 'mixType', 'passDtmf', 'playBeep',
'action', 'auth', 'method', 'url', 'finishOnKey', 'maxLength', 'mixType', 'passDtmf', 'playBeep',
'sampleRate', 'timeout', 'transcribe', 'wsAuth', 'disableBidirectionalAudio', 'channel'
].forEach((k) => this[k] = this.data[k]);
//Escape JSON special characters in metadata
if (this.data.metadata) {
this.metadata = {};
for (const key in this.data.metadata) {
if (this.data.metadata.hasOwnProperty(key)) {
const value = this.data.metadata[key];
this.metadata[key] = typeof value === 'string' ? escapeString(value) : value;
}
}
}
this.mixType = this.mixType || 'mono';
this.sampleRate = this.sampleRate || 8000;
this.earlyMedia = this.data.earlyMedia === true;
@@ -72,7 +94,7 @@ class TaskListen extends Task {
} catch (err) {
this.logger.info(err, `TaskListen:exec - error ${this.url}`);
}
if (this.transcribeTask) this.transcribeTask.kill();
if (this.transcribeTask) this.transcribeTask.kill(cs);
this._removeListeners(ep);
}
@@ -103,9 +125,12 @@ class TaskListen extends Task {
this.notifyTaskDone();
}
async updateListen(status) {
async updateListen(status, silence = false) {
if (!this.killed && this.ep && this.ep.connected) {
const args = this._bugname ? [this._bugname] : [];
const args = [
...(this._bugname ? [this._bugname] : []),
...(status === ListenStatus.Pause ? ([silence]) : []),
];
this.logger.info(`TaskListen:updateListen status ${status}`);
switch (status) {
case ListenStatus.Pause:
@@ -221,7 +246,7 @@ class TaskListen extends Task {
}
}
_onConnect(ep) {
this.logger.debug('TaskListen:_onConnect');
this.logger.info('TaskListen:_onConnect');
}
_onConnectFailure(ep, evt) {
this.logger.info(evt, 'TaskListen:_onConnectFailure');

View File

@@ -4,6 +4,8 @@ const TaskLlmOpenAI_S2S = require('./llms/openai_s2s');
const TaskLlmVoiceAgent_S2S = require('./llms/voice_agent_s2s');
const TaskLlmUltravox_S2S = require('./llms/ultravox_s2s');
const TaskLlmElevenlabs_S2S = require('./llms/elevenlabs_s2s');
const TaskLlmGoogle_S2S = require('./llms/google_s2s');
const LlmMcpService = require('../../utils/llm-mcp');
class TaskLlm extends Task {
constructor(logger, opts) {
@@ -18,6 +20,8 @@ class TaskLlm extends Task {
// delegate to the specific llm model
this.llm = this.createSpecificLlm();
// MCP
this.mcpServers = this.data.mcpServers || [];
}
get name() { return this.llm.name ; }
@@ -28,14 +32,32 @@ class TaskLlm extends Task {
get ep() { return this.cs.ep; }
get mcpService() {
return this.llmMcpService;
}
get isMcpEnabled() {
return this.mcpServers.length > 0;
}
async exec(cs, {ep}) {
await super.exec(cs, {ep});
// create the MCP service if we have MCP servers
if (this.isMcpEnabled) {
this.llmMcpService = new LlmMcpService(this.logger, this.mcpServers);
await this.llmMcpService.init();
}
await this.llm.exec(cs, {ep});
}
async kill(cs) {
super.kill(cs);
await this.llm.kill(cs);
// clean up MCP clients
if (this.isMcpEnabled) {
await this.mcpService.close();
}
}
createSpecificLlm() {
@@ -59,6 +81,10 @@ class TaskLlm extends Task {
llm = new TaskLlmElevenlabs_S2S(this.logger, this.data, this);
break;
case 'google':
llm = new TaskLlmGoogle_S2S(this.logger, this.data, this);
break;
default:
throw new Error(`Unsupported vendor ${this.vendor} for LLM`);
}
@@ -82,8 +108,15 @@ class TaskLlm extends Task {
await this.cs?.requestor.request('llm:event', this.eventHook, data);
}
async sendToolHook(tool_call_id, data) {
await this.cs?.requestor.request('llm:tool-call', this.toolHook, {tool_call_id, ...data});
const tool_response = await this.cs?.requestor.request('llm:tool-call', this.toolHook, {tool_call_id, ...data});
// if the toolHook was a websocket it will return undefined, otherwise it should return an object
if (typeof tool_response != 'undefined') {
tool_response.type = 'client_tool_result';
tool_response.invocation_id = tool_call_id;
this.processToolOutput(tool_call_id, tool_response);
}
}
async processToolOutput(tool_call_id, data) {

View File

@@ -164,7 +164,9 @@ class TaskLlmElevenlabs_S2S extends Task {
try {
const {host, path} = await this.getSignedUrl();
const args = [ep.uuid, 'session.create', this.input_sample_rate, this.output_sample_rate, host, path];
const args = this.conversation_initiation_client_data ?
[ep.uuid, 'session.create', this.input_sample_rate, this.output_sample_rate, host, path] :
[ep.uuid, 'session.create', this.input_sample_rate, this.output_sample_rate, host, path, 'no_initial_config'];
await this._api(ep, args);
} catch (err) {
this.logger.error({err}, 'TaskLlmElevenlabs_S2S:_startListening');
@@ -189,7 +191,7 @@ class TaskLlmElevenlabs_S2S extends Task {
if (this.conversation_initiation_client_data) {
if (!await this._sendClientEvent(ep, {
type: 'conversation_initiation_client_data',
conversation_initiation_client_data: this.conversation_initiation_client_data
...this.conversation_initiation_client_data
})) {
this.notifyTaskDone();
}
@@ -242,13 +244,36 @@ class TaskLlmElevenlabs_S2S extends Task {
/* tool calls */
else if (type === 'client_tool_call') {
this.logger.debug({evt}, 'TaskLlmElevenlabs_S2S:_onServerEvent - function_call');
if (!this.toolHook) {
const {tool_name: name, tool_call_id: call_id, parameters: args} = evt.client_tool_call;
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
if (mcpTools.some((tool) => tool.name === name)) {
this.logger.debug({name, args}, 'TaskLlmElevenlabs_S2S:_onServerEvent - calling mcp tool');
try {
const res = await this.parent.mcpService.callMcpTool(name, args);
this.logger.debug({res}, 'TaskLlmElevenlabs_S2S:_onServerEvent - function_call - mcp result');
this.processToolOutput(ep, call_id, {
data: {
type: 'client_tool_result',
tool_call_id: call_id,
result: res.content?.length ? res.content[0] : res.content,
is_error: false
}
});
return;
}
catch (err) {
this.logger.info({err, evt}, 'TaskLlmElevenlabs_S2S - error calling mcp tool');
this.results = {
completionReason: 'client error calling mcp function',
error: err
};
endConversation = true;
}
} else if (!this.toolHook) {
this.logger.warn({evt}, 'TaskLlmElevenlabs_S2S:_onServerEvent - no toolHook defined!');
}
else {
const {client_tool_call} = evt;
const {tool_name: name, tool_call_id: call_id, parameters: args} = client_tool_call;
try {
await this.parent.sendToolHook(call_id, {name, args});
} catch (err) {

View File

@@ -0,0 +1,319 @@
const Task = require('../../task');
const TaskName = 'Llm_Google_s2s';
const {LlmEvents_Google} = require('../../../utils/constants');
const ClientEvent = 'client.event';
const SessionDelete = 'session.delete';
const google_server_events = [
'error',
'session.created',
'session.updated',
];
const expandWildcards = (events) => {
const expandedEvents = [];
events.forEach((evt) => {
if (evt.endsWith('.*')) {
const prefix = evt.slice(0, -2); // Remove the wildcard ".*"
const matchingEvents = google_server_events.filter((e) => e.startsWith(prefix));
expandedEvents.push(...matchingEvents);
} else {
expandedEvents.push(evt);
}
});
return expandedEvents;
};
class TaskLlmGoogle_S2S extends Task {
constructor(logger, opts, parentTask) {
super(logger, opts, parentTask);
this.parent = parentTask;
this.vendor = this.parent.vendor;
this.vendor = this.parent.vendor;
this.model = this.parent.model || 'models/gemini-2.0-flash-live-001';
this.auth = this.parent.auth;
this.connectionOptions = this.parent.connectOptions;
const {apiKey} = this.auth || {};
if (!apiKey) throw new Error('auth.apiKey is required for Google S2S');
this.apiKey = apiKey;
this.actionHook = this.data.actionHook;
this.eventHook = this.data.eventHook;
this.toolHook = this.data.toolHook;
const {setup} = this.data.llmOptions;
if (typeof setup !== 'object') {
throw new Error('llmOptions with an initial setup is required for Google S2S');
}
this.setup = {
...setup,
model: this.model,
// make sure output is always audio
generationConfig: {
...(setup.generationConfig || {}),
responseModalities: 'audio'
}
};
this.results = {
completionReason: 'normal conversation end'
};
/**
* only one of these will have items,
* if includeEvents, then these are the events to include
* if excludeEvents, then these are the events to exclude
*/
this.includeEvents = [];
this.excludeEvents = [];
/* default to all events if user did not specify */
this._populateEvents(this.data.events || google_server_events);
this.addCustomEventListener = parentTask.addCustomEventListener.bind(parentTask);
this.removeCustomEventListeners = parentTask.removeCustomEventListeners.bind(parentTask);
}
get name() { return TaskName; }
async _api(ep, args) {
const res = await ep.api('uuid_google_s2s', `^^|${args.join('|')}`);
if (!res.body?.startsWith('+OK')) {
throw new Error({args}, `Error calling uuid_openai_s2s: ${res.body}`);
}
}
async exec(cs, {ep}) {
await super.exec(cs);
await this._startListening(cs, ep);
await this.awaitTaskDone();
/* note: the parent llm verb started the span, which is why this is necessary */
await this.parent.performAction(this.results);
this._unregisterHandlers();
}
async kill(cs) {
super.kill(cs);
this._api(cs.ep, [cs.ep.uuid, SessionDelete])
.catch((err) => this.logger.info({err}, 'TaskLlmGoogle_S2S:kill - error deleting session'));
this.notifyTaskDone();
}
_populateEvents(events) {
if (events.includes('all')) {
/* work by excluding specific events */
const exclude = events
.filter((evt) => evt.startsWith('-'))
.map((evt) => evt.slice(1));
if (exclude.length === 0) this.includeEvents = google_server_events;
else this.excludeEvents = expandWildcards(exclude);
}
else {
/* work by including specific events */
const include = events
.filter((evt) => !evt.startsWith('-'));
this.includeEvents = expandWildcards(include);
}
this.logger.debug({
includeEvents: this.includeEvents,
excludeEvents: this.excludeEvents
}, 'TaskLlmGoogle_S2S:_populateEvents');
}
async _startListening(cs, ep) {
this._registerHandlers(ep);
try {
const args = [ep.uuid, 'session.create', this.apiKey];
await this._api(ep, args);
} catch (err) {
this.logger.error({err}, 'TaskLlmGoogle_S2S:_startListening');
this.notifyTaskDone();
}
}
async _sendClientEvent(ep, obj) {
let ok = true;
this.logger.debug({obj}, 'TaskLlmGoogle_S2S:_sendClientEvent');
try {
const args = [ep.uuid, ClientEvent, JSON.stringify(obj)];
await this._api(ep, args);
} catch (err) {
ok = false;
this.logger.error({err}, 'TaskLlmGoogle_S2S:_sendClientEvent - Error');
}
return ok;
}
async _sendInitialMessage(ep) {
const setup = this.setup;
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
if (mcpTools && mcpTools.length > 0) {
const convertedTools = [
{
functionDeclarations: mcpTools.map((tool) => {
if (tool.inputSchema) {
delete tool.inputSchema.additionalProperties;
delete tool.inputSchema['$schema'];
}
return {
name: tool.name,
description: tool.description,
parameters: tool.inputSchema,
};
})
}
];
// merge with any existing tools
setup.tools = [...convertedTools, ...(this.setup.tools || [])];
}
if (!await this._sendClientEvent(ep, {
setup,
})) {
this.logger.debug(this.setup, 'TaskLlmGoogle_S2S:_sendInitialMessage - sending session.update');
this.notifyTaskDone();
}
}
_registerHandlers(ep) {
this.addCustomEventListener(ep, LlmEvents_Google.Connect, this._onConnect.bind(this, ep));
this.addCustomEventListener(ep, LlmEvents_Google.ConnectFailure, this._onConnectFailure.bind(this, ep));
this.addCustomEventListener(ep, LlmEvents_Google.Disconnect, this._onDisconnect.bind(this, ep));
this.addCustomEventListener(ep, LlmEvents_Google.ServerEvent, this._onServerEvent.bind(this, ep));
}
_unregisterHandlers() {
this.removeCustomEventListeners();
}
_onError(ep, evt) {
this.logger.info({evt}, 'TaskLlmGoogle_S2S:_onError');
this.notifyTaskDone();
}
_onConnect(ep) {
this.logger.debug('TaskLlmGoogle_S2S:_onConnect');
this._sendInitialMessage(ep);
}
_onConnectFailure(_ep, evt) {
this.logger.info(evt, 'TaskLlmGoogle_S2S:_onConnectFailure');
this.results = {completionReason: 'connection failure'};
this.notifyTaskDone();
}
_onDisconnect(_ep, evt) {
this.logger.info(evt, 'TaskLlmGoogle_S2S:_onConnectFailure');
this.results = {completionReason: 'disconnect from remote end'};
this.notifyTaskDone();
}
async _onServerEvent(ep, evt) {
let endConversation = false;
this.logger.debug({evt}, 'TaskLlmGoogle_S2S:_onServerEvent');
const {toolCall /**toolCallCancellation*/} = evt;
if (toolCall) {
this.logger.debug({toolCall}, 'TaskLlmGoogle_S2S:_onServerEvent - toolCall');
if (!this.toolHook) {
this.logger.info({evt}, 'TaskLlmGoogle_S2S:_onServerEvent - no toolHook defined!');
}
else {
const {functionCalls} = toolCall;
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
const functionResponses = [];
if (mcpTools && mcpTools.length > 0) {
for (const functionCall of functionCalls) {
const {name, args, id} = functionCall;
const tool = mcpTools.find((tool) => tool.name === name);
if (tool) {
const response = await this.parent.mcpService.callMcpTool(name, args);
functionResponses.push({
response: {
output: response,
},
id
});
}
}
}
if (functionResponses && functionResponses.length > 0) {
this.logger.debug({functionResponses}, 'TaskLlmGoogle_S2S:_onServerEvent - function_call - mcp result');
this.processToolOutput(ep, 'tool_call_id', {
toolResponse: {
functionResponses
}
});
} else {
try {
await this.parent.sendToolHook('function_call_id', {type: 'toolCall', functionCalls});
} catch (err) {
this.logger.info({err, evt}, 'TaskLlmGoogle_S2S - error calling function');
this.results = {
completionReason: 'client error calling function',
error: err
};
endConversation = true;
}
}
}
}
this._sendLlmEvent('llm_event', evt);
if (endConversation) {
this.logger.info({results: this.results},
'TaskLlmGoogle_S2S:_onServerEvent - ending conversation due to error');
this.notifyTaskDone();
}
}
_sendLlmEvent(type, evt) {
/* check whether we should notify on this event */
if (this.includeEvents.length > 0 ? this.includeEvents.includes(type) : !this.excludeEvents.includes(type)) {
this.parent.sendEventHook(evt)
.catch((err) => this.logger.info({err}, 'TaskLlmGoogle_S2S:_onServerEvent - error sending event hook'));
}
}
async processLlmUpdate(ep, data, _callSid) {
try {
this.logger.debug({data, _callSid}, 'TaskLlmGoogle_S2S:processLlmUpdate');
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(data)]);
} catch (err) {
this.logger.info({err, data}, 'TaskLlmGoogle_S2S:processLlmUpdate - Error processing LLM update');
}
}
async processToolOutput(ep, tool_call_id, data) {
try {
this.logger.debug({tool_call_id, data}, 'TaskLlmGoogle_S2S:processToolOutput');
const {toolResponse} = data;
if (!toolResponse) {
this.logger.info({data},
'TaskLlmGoogle_S2S:processToolOutput - invalid tool output, must be functionResponses');
}
else {
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(data)]);
}
} catch (err) {
this.logger.info({err, data}, 'TaskLlmGoogle_S2S:processToolOutput - Error processing tool output');
}
}
}
module.exports = TaskLlmGoogle_S2S;

View File

@@ -235,6 +235,23 @@ class TaskLlmOpenAI_S2S extends Task {
/* send immediate session.update if present */
else if (this.session_update) {
if (this.parent.isMcpEnabled) {
this.logger.debug('TaskLlmOpenAI_S2S:_sendInitialMessage - mcp enabled');
const tools = await this.parent.mcpService.getAvailableMcpTools();
if (tools && tools.length > 0 && this.session_update) {
const convertedTools = tools.map((tool) => ({
name: tool.name,
type: 'function',
description: tool.description,
parameters: tool.inputSchema
}));
this.session_update.tools = [
...convertedTools,
...(this.session_update.tools || [])
];
}
}
obj = {type: 'session.update', session: this.session_update};
this.logger.debug({obj}, 'TaskLlmOpenAI_S2S:_sendInitialMessage - sending session.update');
if (!await this._sendClientEvent(ep, obj)) {
@@ -299,13 +316,37 @@ class TaskLlmOpenAI_S2S extends Task {
/* tool calls */
else if (type === 'response.output_item.done' && evt.item?.type === 'function_call') {
this.logger.debug({evt}, 'TaskLlmOpenAI_S2S:_onServerEvent - function_call');
if (!this.toolHook) {
const {name, call_id} = evt.item;
const args = JSON.parse(evt.item.arguments);
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
if (mcpTools.some((tool) => tool.name === name)) {
this.logger.debug({call_id, name, args}, 'TaskLlmOpenAI_S2S:_onServerEvent - calling mcp tool');
try {
const res = await this.parent.mcpService.callMcpTool(name, args);
this.logger.debug({res}, 'TaskLlmOpenAI_S2S:_onServerEvent - function_call - mcp result');
this.processToolOutput(ep, call_id, {
type: 'conversation.item.create',
item: {
type: 'function_call_output',
call_id,
output: res.content[0]?.text || 'There is no output from the function call',
}
});
return;
} catch (err) {
this.logger.info({err, evt}, 'TaskLlmOpenAI_S2S - error calling function');
this.results = {
completionReason: 'client error calling mcp function',
error: err
};
endConversation = true;
}
}
else if (!this.toolHook) {
this.logger.warn({evt}, 'TaskLlmOpenAI_S2S:_onServerEvent - no toolHook defined!');
}
else {
const {name, call_id} = evt.item;
const args = JSON.parse(evt.item.arguments);
try {
await this.parent.sendToolHook(call_id, {name, args});
} catch (err) {

View File

@@ -32,12 +32,18 @@ class TaskLlmUltravox_S2S extends Task {
this.auth = this.parent.auth;
this.connectionOptions = this.parent.connectOptions;
const {apiKey} = this.auth || {};
const {apiKey, agent_id} = this.auth || {};
if (!apiKey) throw new Error('auth.apiKey is required for Vendor: Ultravox');
this.apiKey = apiKey;
this.agentId = agent_id;
this.actionHook = this.data.actionHook;
this.eventHook = this.data.eventHook;
this.toolHook = this.data.toolHook;
this.llmOptions = this.data.llmOptions || {};
this.results = {
completionReason: 'normal conversation end'
};
/**
* only one of these will have items,
@@ -63,19 +69,67 @@ class TaskLlmUltravox_S2S extends Task {
}
}
/**
* Converts a JSON Schema to the dynamic parameters format used in the Ultravox API
* @param {Object} jsonSchema - A JSON Schema object defining parameters
* @param {string} locationDefault - Default location value for parameters (default: 'PARAMETER_LOCATION_BODY')
* @returns {Array} Array of dynamic parameters objects
*/
transformSchemaToParameters(jsonSchema, locationDefault = 'PARAMETER_LOCATION_BODY') {
if (jsonSchema.properties) {
const required = jsonSchema.required || [];
return Object.entries(jsonSchema.properties).map(([name]) => {
return {
name,
location: locationDefault,
required: required.includes(name)
};
});
}
return [];
}
async createCall() {
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
if (mcpTools && mcpTools.length > 0) {
const convertedTools = mcpTools.map((tool) => {
return {
temporaryTool: {
modelToolName: tool.name,
description: tool.description,
dynamicParameters: this.transformSchemaToParameters(tool.inputSchema),
// use client tool that ultravox call tool via freeswitch module.
client: {}
}
};
}
);
// merge with any existing tools
this.llmOptions.selectedTools = [
...convertedTools,
...(this.llmOptions.selectedTools || [])
];
}
const payload = {
...this.data.llmOptions,
model: this.model,
...this.llmOptions,
...(!this.agentId && {
model: this.model,
}),
medium: {
...(this.data.llmOptions.medium || {}),
...(this.llmOptions.medium || {}),
serverWebSocket: {
inputSampleRate: 8000,
outputSampleRate: 8000,
}
}
};
const {statusCode, body} = await request('https://api.ultravox.ai/api/calls', {
const baseUrl = 'https://api.ultravox.ai';
const url = this.agentId ?
`${baseUrl}/api/agents/${this.agentId}/calls` : `${baseUrl}/api/calls`;
const {statusCode, body} = await request(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -85,10 +139,10 @@ class TaskLlmUltravox_S2S extends Task {
});
const data = await body.json();
if (statusCode !== 201 || !data?.joinUrl) {
this.logger.error({statusCode, data}, 'Ultravox Error registering call');
throw new Error(`Ultravox Error registering call: ${data.message}`);
this.logger.info({statusCode, data}, 'Ultravox Error registering call');
throw new Error(`Ultravox Error registering call:${statusCode} - ${data.detail}`);
}
this.logger.info({joinUrl: data.joinUrl}, 'Ultravox Call registered');
this.logger.debug({joinUrl: data.joinUrl}, 'Ultravox Call registered');
return data;
}
@@ -106,12 +160,11 @@ class TaskLlmUltravox_S2S extends Task {
async _startListening(cs, ep) {
this._registerHandlers(ep);
const data = await this.createCall();
const {joinUrl} = data;
// split the joinUrl into host and path
const {host, pathname, search} = new URL(joinUrl);
try {
const data = await this.createCall();
const {joinUrl} = data;
// split the joinUrl into host and path
const {host, pathname, search} = new URL(joinUrl);
const args = [ep.uuid, 'session.create', host, pathname + search];
await this._api(ep, args);
// Notify the application that the session has been created with detail information
@@ -120,7 +173,8 @@ class TaskLlmUltravox_S2S extends Task {
...data
});
} catch (err) {
this.logger.error({err}, 'TaskLlmUltraVox_S2S:_startListening');
this.logger.info({err}, 'TaskLlmUltraVox_S2S:_startListening - Error sending createCall');
this.results = {completionReason: `connection failure - ${err}`};
this.notifyTaskDone();
}
}
@@ -148,7 +202,7 @@ class TaskLlmUltravox_S2S extends Task {
}
_onConnect(ep) {
this.logger.debug('TaskLlmUltravox_S2S:_onConnect');
this.logger.info('TaskLlmUltravox_S2S:_onConnect');
}
_onConnectFailure(_ep, evt) {
this.logger.info(evt, 'TaskLlmUltravox_S2S:_onConnectFailure');
@@ -164,7 +218,7 @@ class TaskLlmUltravox_S2S extends Task {
async _onServerEvent(_ep, evt) {
let endConversation = false;
const type = evt.type;
this.logger.info({evt}, 'TaskLlmUltravox_S2S:_onServerEvent');
//this.logger.debug({evt}, 'TaskLlmUltravox_S2S:_onServerEvent');
/* server errors of some sort */
if (type === 'error') {
@@ -178,12 +232,35 @@ class TaskLlmUltravox_S2S extends Task {
/* tool calls */
else if (type === 'client_tool_invocation') {
this.logger.debug({evt}, 'TaskLlmUltravox_S2S:_onServerEvent - function_call');
if (!this.toolHook) {
this.logger.warn({evt}, 'TaskLlmUltravox_S2S:_onServerEvent - no toolHook defined!');
const {toolName: name, invocationId: call_id, parameters: args} = evt;
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
if (mcpTools.some((tool) => tool.name === name)) {
this.logger.debug({
name,
input: args
}, 'TaskLlmUltravox_S2S:_onServerEvent - function_call - mcp tool');
try {
const res = await this.parent.mcpService.callMcpTool(name, args);
this.logger.debug({res}, 'TaskLlmUltravox_S2S:_onServerEvent - function_call - mcp result');
this.processToolOutput(_ep, call_id, {
type: 'client_tool_result',
invocation_id: call_id,
result: res.content
});
return;
} catch (err) {
this.logger.info({err, evt}, 'TaskLlmUltravox_S2S - error calling mcp tool');
this.results = {
completionReason: 'client error calling mcp function',
error: err
};
endConversation = true;
}
} else if (!this.toolHook) {
this.logger.info({evt}, 'TaskLlmUltravox_S2S:_onServerEvent - no toolHook defined!');
}
else {
const {toolName: name, invocationId: call_id, parameters: args} = evt;
try {
await this.parent.sendToolHook(call_id, {name, args});
} catch (err) {
@@ -221,13 +298,14 @@ class TaskLlmUltravox_S2S extends Task {
if (!data.type || ![
'input_text_message'
].includes(data.type)) {
this.logger.info({data}, 'TaskLlmUltravox_S2S:processLlmUpdate - invalid mid-call request');
this.logger.info({data},
'TaskLlmUltravox_S2S:processLlmUpdate - invalid mid-call request, only input_text_message supported');
}
else {
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(data)]);
}
} catch (err) {
this.logger.info({err}, 'TaskLlmUltravox_S2S:processLlmUpdate');
this.logger.info({err, data}, 'TaskLlmUltravox_S2S:processLlmUpdate - Error processing LLM update');
}
}
@@ -243,7 +321,7 @@ class TaskLlmUltravox_S2S extends Task {
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(data)]);
}
} catch (err) {
this.logger.info({err}, 'TaskLlmUltravox_S2S:processToolOutput');
this.logger.info({err, data}, 'TaskLlmUltravox_S2S:processToolOutput - Error processing tool output');
}
}

View File

@@ -29,7 +29,7 @@ class TaskLlmVoiceAgent_S2S extends Task {
this.parent = parentTask;
this.vendor = this.parent.vendor;
this.model = this.parent.model;
this.model = this.parent.model || 'voice-agent';
this.auth = this.parent.auth;
this.connectionOptions = this.parent.connectOptions;
@@ -41,25 +41,25 @@ class TaskLlmVoiceAgent_S2S extends Task {
this.actionHook = this.data.actionHook;
this.eventHook = this.data.eventHook;
this.toolHook = this.data.toolHook;
const {settingsConfiguration} = this.data.llmOptions;
const {Settings} = this.data.llmOptions;
if (typeof settingsConfiguration !== 'object') {
throw new Error('llmOptions with an initial settingsConfiguration is required for VoiceAgent S2S');
if (typeof Settings !== 'object') {
throw new Error('llmOptions with an initial Settings is required for VoiceAgent S2S');
}
// eslint-disable-next-line no-unused-vars
const {audio, ...rest} = settingsConfiguration;
const cfg = this.settingsConfiguration = rest;
const {audio, ...rest} = Settings;
const cfg = this.Settings = rest;
if (!cfg.agent) throw new Error('llmOptions.settingsConfiguration.agent is required for VoiceAgent S2S');
if (!cfg.agent) throw new Error('llmOptions.Settings.agent is required for VoiceAgent S2S');
if (!cfg.agent.think) {
throw new Error('llmOptions.settingsConfiguration.agent.think is required for VoiceAgent S2S');
throw new Error('llmOptions.Settings.agent.think is required for VoiceAgent S2S');
}
if (!cfg.agent.think.model) {
throw new Error('llmOptions.settingsConfiguration.agent.think.model is required for VoiceAgent S2S');
if (!cfg.agent.think.provider?.model) {
throw new Error('llmOptions.Settings.agent.think.provider.model is required for VoiceAgent S2S');
}
if (!cfg.agent.think.provider?.type) {
throw new Error('llmOptions.settingsConfiguration.agent.think.provider.type is required for VoiceAgent S2S');
throw new Error('llmOptions.Settings.agent.think.provider.type is required for VoiceAgent S2S');
}
this.results = {
@@ -92,7 +92,7 @@ class TaskLlmVoiceAgent_S2S extends Task {
const {path} = this.connectionOptions || {};
if (path) return path;
return '/agent';
return '/v1/agent/converse';
}
async _api(ep, args) {
@@ -193,7 +193,20 @@ class TaskLlmVoiceAgent_S2S extends Task {
}
async _sendInitialMessage(ep) {
if (!await this._sendClientEvent(ep, this.settingsConfiguration)) {
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
if (mcpTools && mcpTools.length > 0 && this.Settings.agent?.think) {
const convertedTools = mcpTools.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: tool.inputSchema
}));
this.Settings.agent.think.functions = [
...convertedTools,
...(this.Settings.agent.think?.functions || [])
];
}
if (!await this._sendClientEvent(ep, this.Settings)) {
this.notifyTaskDone();
}
}
@@ -254,17 +267,43 @@ class TaskLlmVoiceAgent_S2S extends Task {
/* tool calls */
else if (type === 'FunctionCallRequest') {
this.logger.debug({evt}, 'TaskLlmVoiceAgent_S2S:_onServerEvent - function_call');
if (!this.toolHook) {
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
if (!this.toolHook && mcpTools.length === 0) {
this.logger.warn({evt}, 'TaskLlmVoiceAgent_S2S:_onServerEvent - no toolHook defined!');
}
else {
const {function_name:name, function_call_id:call_id} = evt;
const args = evt.input;
} else {
const {functions} = evt;
const handledFunctions = [];
try {
await this.parent.sendToolHook(call_id, {name, args});
if (mcpTools && mcpTools.length > 0) {
for (const func of functions) {
const {name, arguments: args, id} = func;
const tool = mcpTools.find((tool) => tool.name === name);
if (tool) {
handledFunctions.push(name);
const response = await this.parent.mcpService.callMcpTool(name, JSON.parse(args));
this.logger.debug({response}, 'TaskLlmVoiceAgent_S2S:_onServerEvent - function_call - mcp result');
this.processToolOutput(_ep, id, {
data: {
type: 'FunctionCallResponse',
id,
name,
content: response.length > 0 ? response[0].text : 'There is no output from the function call'
}
});
}
}
}
for (const func of functions) {
const {name, arguments: args, id} = func;
if (!handledFunctions.includes(name)) {
await this.parent.sendToolHook(id, {name, args: JSON.parse(args)});
}
}
} catch (err) {
this.logger.info({err, evt}, 'TaskLlmVoiceAgent - error calling function');
this.logger.info({err, evt}, 'TaskLlmVoiceAgent_S2S:_onServerEvent - error calling function');
this.results = {
completionReason: 'client error calling function',
error: err

View File

@@ -96,6 +96,9 @@ function makeTask(logger, obj, parent) {
case TaskName.Tag:
const TaskTag = require('./tag');
return new TaskTag(logger, data, parent);
case TaskName.Alert:
const TaskAlert = require('./alert');
return new TaskAlert(logger, data, parent);
}
// should never reach

View File

@@ -1,7 +1,7 @@
const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants');
const bent = require('bent');
const uuidv4 = require('uuid-random');
const crypto = require('crypto');
const {K8S} = require('../config');
class TaskMessage extends Task {
constructor(logger, opts) {
@@ -9,7 +9,7 @@ class TaskMessage extends Task {
this.preconditions = TaskPreconditions.None;
this.payload = {
message_sid: this.data.message_sid || uuidv4(),
message_sid: this.data.message_sid || crypto.randomUUID(),
carrier: this.data.carrier,
to: this.data.to,
from: this.data.from,

View File

@@ -1,13 +1,26 @@
const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants');
const { PlayFileNotFoundError } = require('../utils/error');
class TaskPlay extends Task {
constructor(logger, opts) {
super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint;
this.url = this.data.url;
//Cleanup URLs that contain a querystring with a . unless that querystring is the filename
// see https://github.com/jambonz/jambonz-feature-server/pull/1293
// and https://github.com/jambonz/jambonz-feature-server/issues/1394 for background
if (this.data.url.includes('?')) {
if (['.mp3', '.wav'].includes(this.data.url.slice(-4))) {
this.url = this.data.url;
}
else {
this.url = this.data.url.split('?')[0] + '?' + this.data.url.split('?')[1].replaceAll('.', '%2E');
}
}
else {
this.url = this.data.url;
}
this.seekOffset = this.data.seekOffset || -1;
this.timeoutSecs = this.data.timeoutSecs || -1;
this.loop = this.data.loop || 1;
@@ -27,6 +40,7 @@ class TaskPlay extends Task {
let playbackSeconds = 0;
let playbackMilliseconds = 0;
let completed = !(this.timeoutSecs > 0 || this.loop);
cs.playingAudio = true;
if (this.timeoutSecs > 0) {
timeout = setTimeout(async() => {
completed = true;
@@ -40,6 +54,22 @@ class TaskPlay extends Task {
try {
this.notifyStatus({event: 'start-playback'});
while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep.connected) {
/* Listen for playback-start event and set up a one-time listener for uuid_break
* that will kill the audio playback if the taskIds match. This ensures that
* we only kill the currently playing audio and not audio from other tasks.
* As we are using stickyEventEmitter, even if the event is emitted before the listener is registered,
* the listener will receive the most recent event.
*/
ep.once('playback-start', (evt) => {
this.logger.debug({evt}, 'Play got playback-start');
this.cs.stickyEventEmitter?.once('uuid_break', (t) => {
if (t?.taskId === this.taskId) {
this.logger.debug(`Play got kill-playback, executing uuid_break, taskId: ${t?.taskId}`);
this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
this.notifyStatus({event: 'kill-playback'});
}
});
});
if (cs.isInConference) {
const {memberId, confName, confUuid} = cs;
if (Array.isArray(this.url)) {
@@ -87,15 +117,15 @@ class TaskPlay extends Task {
async kill(cs) {
super.kill(cs);
if (this.ep.connected && !this.playComplete) {
if (this.ep?.connected && !this.playComplete) {
this.logger.debug('TaskPlay:kill - killing audio');
if (cs.isInConference) {
const {memberId, confName} = cs;
this.killPlayToConfMember(this.ep, memberId, confName);
}
else {
this.notifyStatus({event: 'kill-playback'});
this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
//this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
cs.stickyEventEmitter.emit('uuid_break', this);
}
}
}

View File

@@ -17,28 +17,33 @@ class TaskRedirect extends Task {
async exec(cs) {
await super.exec(cs);
if (cs.requestor instanceof WsRequestor && cs.application.requestor._isAbsoluteUrl(this.actionHook)) {
this.logger.info(`Task:performAction redirecting to ${this.actionHook}, requires new ws connection`);
try {
this.cs.requestor.close();
const requestor = new WsRequestor(this.logger, cs.accountSid, {url: this.actionHook}, this.webhook_secret) ;
this.cs.application.requestor = requestor;
} catch (err) {
this.logger.info(err, `Task:performAction error redirecting to ${this.actionHook}`);
}
} else if (cs.application.requestor._isAbsoluteUrl(this.actionHook)) {
const baseUrl = this.cs.application.requestor.baseUrl;
const newUrl = URL.parse(this.actionHook);
const newBaseUrl = newUrl.protocol + '//' + newUrl.host;
if (baseUrl != newBaseUrl) {
const isAbsoluteUrl = cs.application?.requestor?._isAbsoluteUrl(this.actionHook);
if (isAbsoluteUrl) {
this.logger.info(`TaskRedirect redirecting to new absolute URL ${this.actionHook}, requires new requestor`);
if (cs.requestor instanceof WsRequestor) {
try {
this.logger.info(`Task:redirect updating base url to ${newBaseUrl}`);
const newRequestor = new HttpRequestor(this.logger, cs.accountSid, {url: this.actionHook},
cs.accountInfo.account.webhook_secret);
this.cs.requestor.removeAllListeners();
this.cs.application.requestor = newRequestor;
const requestor = new WsRequestor(this.logger, cs.accountSid, {url: this.actionHook},
cs.accountInfo.account.webhook_secret) ;
cs.requestor.emit('handover', requestor);
} catch (err) {
this.logger.info(err, `Task:redirect error updating base url to ${this.actionHook}`);
this.logger.info(err, `TaskRedirect error redirecting to ${this.actionHook}`);
}
}
else {
const baseUrl = this.cs.application.requestor.baseUrl;
const newUrl = URL.parse(this.actionHook);
const newBaseUrl = newUrl.protocol + '//' + newUrl.host;
if (baseUrl != newBaseUrl) {
try {
this.logger.info(`Task:redirect updating base url to ${newBaseUrl}`);
const newRequestor = new HttpRequestor(this.logger, cs.accountSid, {url: this.actionHook},
cs.accountInfo.account.webhook_secret);
cs.requestor.emit('handover', newRequestor);
} catch (err) {
this.logger.info(err, `TaskRedirect error updating base url to ${this.actionHook}`);
}
}
}
}

View File

@@ -19,6 +19,7 @@ class TaskRestDial extends Task {
this.timeout = this.data.timeout || 60;
this.sipRequestWithinDialogHook = this.data.sipRequestWithinDialogHook;
this.referHook = this.data.referHook;
this.recentCallStatus = 0;
this.on('connect', this._onConnect.bind(this));
this.on('callStatus', this._onCallStatus.bind(this));
@@ -57,7 +58,11 @@ class TaskRestDial extends Task {
this._clearCallTimer();
if (this.canCancel) {
this.canCancel = false;
cs?.req?.cancel();
try {
cs?.req?.cancel();
} catch (err) {
this.logger.error({err}, 'TaskRestDial: error cancelling call');
}
}
this.notifyTaskDone();
}
@@ -77,6 +82,7 @@ class TaskRestDial extends Task {
const httpHeaders = b3 && {b3};
const params = {
...(cs.callInfo.toJSON()),
...(this.env_vars && {env_vars: this.env_vars}),
defaults: {
synthesizer: {
vendor: cs.speechSynthesisVendor,
@@ -117,7 +123,8 @@ class TaskRestDial extends Task {
}
_onCallStatus(status) {
this.logger.debug(`CallStatus: ${status}`);
this.logger.debug(`RestDial CallStatus: ${status}`);
this.recentCallStatus = status;
if (status >= 200) {
this.canCancel = false;
this._clearCallTimer();
@@ -135,11 +142,16 @@ class TaskRestDial extends Task {
}
_onCallTimeout() {
this.logger.debug('TaskRestDial: timeout expired without answer, killing task');
this.logger.debug(`TaskRestDial: timeout expired without answer, last status ${this.recentCallStatus}`);
this.timer = null;
if (this.canCancel) {
if (this.canCancel && this.recentCallStatus < 200) {
this.logger.debug('TaskRestDial: cancelling call attempt');
this.canCancel = false;
this.cs?.req?.cancel();
try {
this.cs?.req?.cancel();
} catch (err) {
this.logger.error({err}, 'TaskRestDial: error cancelling call');
}
}
}

View File

@@ -2,25 +2,56 @@ const assert = require('assert');
const TtsTask = require('./tts-task');
const {TaskName, TaskPreconditions} = require('../utils/constants');
const pollySSMLSplit = require('polly-ssml-split');
const { SpeechCredentialError } = require('../utils/error');
const { SpeechCredentialError, NonFatalTaskError } = require('../utils/error');
const { sleepFor } = require('../utils/helpers');
const { NON_FANTAL_ERRORS } = require('../utils/constants.json');
const breakLengthyTextIfNeeded = (logger, text) => {
const chunkSize = 1000;
/**
* Discard unmatching responses:
* (1) I sent a playback id but get a response with a different playback id
* (2) I sent a playback id but get a response with no playback id
* (3) I did not send a playback id but get a response with a playback id
* (4) I sent a cache file but get a response with a different cache file
*/
const isMatchingEvent = (logger, filename, playbackId, evt) => {
if (!!playbackId && !!evt.variable_tts_playback_id && evt.variable_tts_playback_id === playbackId) {
//logger.debug({filename, playbackId, evt}, 'Say:isMatchingEvent - playbackId matched');
return true;
}
if (!!filename && !!evt.file && evt.file === filename) {
//logger.debug({filename, playbackId, evt}, 'Say:isMatchingEvent - filename matched');
return true;
}
logger.info({filename, playbackId, evt}, 'Say:isMatchingEvent - no match');
return false;
};
const breakLengthyTextIfNeeded = (logger, text) => {
// As The text can be used for tts streaming, we need to break lengthy text into smaller chunks
// HIGH_WATER_BUFFER_SIZE defined in tts-streaming-buffer.js
const chunkSize = 900;
const isSSML = text.startsWith('<speak>');
if (text.length <= chunkSize || !isSSML) return [text];
const options = {
// MIN length
softLimit: 100,
// MAX length, exclude 15 characters <speak></speak>
hardLimit: chunkSize - 15,
// Set of extra split characters (Optional property)
extraSplitChars: ',;!?',
};
pollySSMLSplit.configure(options);
try {
return pollySSMLSplit.split(text);
if (text.length <= chunkSize) return [text];
if (isSSML) {
return pollySSMLSplit.split(text);
} else {
// Wrap with <speak> and split
const wrapped = `<speak>${text}</speak>`;
const splitArr = pollySSMLSplit.split(wrapped);
// Remove <speak> and </speak> from each chunk
return splitArr.map((str) => str.replace(/^<speak>/, '').replace(/<\/speak>$/, ''));
}
} catch (err) {
logger.info({err}, 'Error spliting SSML long text');
logger.info({err}, 'Error splitting SSML long text');
return [text];
}
};
@@ -39,6 +70,9 @@ class TaskSay extends TtsTask {
assert.ok((typeof this.data.text === 'string' || Array.isArray(this.data.text)) || this.data.stream === true,
'Say: either text or stream:true is required');
this.text = this.data.text ? (Array.isArray(this.data.text) ? this.data.text : [this.data.text])
.map((t) => breakLengthyTextIfNeeded(this.logger, t))
.flat() : [];
if (this.data.stream === true) {
this._isStreamingTts = true;
@@ -46,10 +80,6 @@ class TaskSay extends TtsTask {
}
else {
this._isStreamingTts = false;
this.text = (Array.isArray(this.data.text) ? this.data.text : [this.data.text])
.map((t) => breakLengthyTextIfNeeded(this.logger, t))
.flat();
this.loop = this.data.loop || 1;
this.isHandledByPrimaryProvider = true;
}
@@ -85,15 +115,17 @@ class TaskSay extends TtsTask {
}
try {
this._isStreamingTts = this._isStreamingTts || cs.autoStreamTts;
if (this.isStreamingTts) {
this.closeOnStreamEmpty = this.closeOnStreamEmpty || this.text.length !== 0;
}
if (this.isStreamingTts) await this.handlingStreaming(cs, obj);
else await this.handling(cs, obj);
this.emit('playDone');
} catch (error) {
if (error instanceof SpeechCredentialError) {
// if say failed due to speech credentials, alarm is writtern and error notification is sent
// finished this say to move to next task.
this.logger.info({error}, 'Say failed due to SpeechCredentialError, finished!');
this.emit('playDone');
return;
}
throw error;
@@ -107,15 +139,60 @@ class TaskSay extends TtsTask {
throw new SpeechCredentialError(
`No text-to-speech service credentials for ${vendor} with labels: ${label} have been configured`);
}
this.ep = ep;
try {
await this.setTtsStreamingChannelVars(vendor, language, voice, credentials, ep);
await cs.startTtsStream();
cs.requestor?.request('tts:streaming-event', '/streaming-event', {event_type: 'stream_open'})
.catch((err) => this.logger.info({err}, 'TaskSay:handlingStreaming - Error sending'));
if (this.text.length !== 0) {
this.logger.info('TaskSay:handlingStreaming - sending text to TTS stream');
for (const t of this.text) {
const result = await cs._internalTtsStreamingBufferTokens(t);
if (result?.status === 'failed') {
if (result.reason === 'full') {
// Retry logic for full buffer
const maxRetries = 5;
let backoffMs = 1000;
for (let retryCount = 0; retryCount < maxRetries && !this.killed; retryCount++) {
this.logger.info(
`TaskSay:handlingStreaming - retry ${retryCount + 1}/${maxRetries} after ${backoffMs}ms`);
await sleepFor(backoffMs);
const retryResult = await cs._internalTtsStreamingBufferTokens(t);
// Exit retry loop on success
if (retryResult?.status !== 'failed') {
break;
}
// Handle failure for reason other than full buffer
if (retryResult.reason !== 'full') {
this.logger.info(
{result: retryResult}, 'TaskSay:handlingStreaming - TTS stream failed to buffer tokens');
throw new Error(`TTS stream failed to buffer tokens: ${retryResult.reason}`);
}
// Last retry attempt failed
if (retryCount === maxRetries - 1) {
this.logger.info('TaskSay:handlingStreaming - Maximum retries exceeded for full buffer');
throw new Error('TTS stream buffer full - maximum retries exceeded');
}
// Increase backoff for next retry
backoffMs = Math.min(backoffMs * 1.5, 10000);
}
} else {
// Immediate failure for non-full buffer issues
this.logger.info({result}, 'TaskSay:handlingStreaming - TTS stream failed to buffer tokens');
throw new Error(`TTS stream failed to buffer tokens: ${result.reason}`);
}
} else {
await cs._lccTtsFlush();
}
}
}
} catch (err) {
this.logger.info({err}, 'TaskSay:handlingStreaming - Error setting channel vars');
cs.requestor?.request('tts:streaming-event', '/streaming-event', {event_type: 'stream_closed'})
@@ -188,6 +265,7 @@ class TaskSay extends TtsTask {
throw new SpeechCredentialError(error.message);
}
};
let filepath;
try {
filepath = await this._synthesizeWithSpecificVendor(cs, ep, {vendor, language, voice, label});
@@ -199,63 +277,145 @@ class TaskSay extends TtsTask {
while (!this.killed && (this.loop === 'forever' || this.loop--) && ep?.connected) {
let segment = 0;
while (!this.killed && segment < filepath.length) {
const filename = filepath[segment];
if (cs.isInConference) {
const {memberId, confName, confUuid} = cs;
await this.playToConfMember(ep, memberId, confName, confUuid, filepath[segment]);
await this.playToConfMember(ep, memberId, confName, confUuid, filename);
}
else {
const isStreaming = filepath[segment].startsWith('say:{');
const isStreaming = filename.startsWith('say:{');
if (isStreaming) {
const arr = /^say:\{.*\}\s*(.*)$/.exec(filepath[segment]);
if (arr) this.logger.debug(`Say:exec sending streaming tts request: ${arr[1].substring(0, 64)}..`);
const arr = /^say:\{.*\}\s*(.*)$/.exec(filename);
if (arr) this.logger.debug(`Say:exec sending streaming tts request ${arr[1].substring(0, 64)}..`);
else this.logger.debug(`Say:exec sending ${filename.substring(0, 64)}`);
}
else this.logger.debug(`Say:exec sending ${filepath[segment].substring(0, 64)}`);
ep.once('playback-start', (evt) => {
this.logger.debug({evt}, 'Say got playback-start');
if (this.otelSpan) {
this._addStreamingTtsAttributes(this.otelSpan, evt, vendor);
this.otelSpan.end();
this.otelSpan = null;
if (evt.variable_tts_cache_filename) {
cs.trackTmpFile(evt.variable_tts_cache_filename);
}
}
});
ep.once('playback-stop', (evt) => {
this.logger.debug({evt}, 'Say got playback-stop');
if (evt.variable_tts_error) {
writeAlerts({
account_sid,
alert_type: AlertType.TTS_FAILURE,
vendor,
detail: evt.variable_tts_error,
target_sid
}).catch((err) => this.logger.info({err}, 'Error generating alert for no tts'));
}
if (evt.variable_tts_cache_filename && !this.killed) {
const text = parseTextFromSayString(this.text[segment]);
addFileToCache(evt.variable_tts_cache_filename, {
account_sid,
vendor,
language,
voice,
engine,
model: this.model || this.model_id,
text
}).catch((err) => this.logger.info({err}, 'Error adding file to cache'));
}
if (this._playResolve) {
evt.variable_tts_error ? this._playReject(new Error(evt.variable_tts_error)) : this._playResolve();
const onPlaybackStop = (evt) => {
try {
const playbackId = this.getPlaybackId(segment);
const isMatch = isMatchingEvent(this.logger, filename, playbackId, evt);
if (!isMatch) {
this.logger.info({currentPlaybackId: playbackId, stopPlaybackId: evt.variable_tts_playback_id},
'Say:exec discarding playback-stop for earlier play');
ep.once('playback-stop', this._boundOnPlaybackStop);
return;
}
this.logger.debug({evt},
`Say got playback-stop ${evt.variable_tts_playback_id ? evt.variable_tts_playback_id : ''}`);
this.notifyStatus({event: 'stop-playback'});
this.notifiedPlayBackStop = true;
const tts_error = evt.variable_tts_error;
// some tts vendor may not provide response code, so we default to 200
let response_code = 200;
// Check if any property ends with _response_code
for (const [key, value] of Object.entries(evt)) {
if (key.endsWith('_response_code')) {
response_code = parseInt(value, 10);
if (isNaN(response_code)) {
this.logger.info(`Say:exec playback-stop - Invalid response code: ${value}`);
response_code = 0;
}
break;
}
}
if (tts_error ||
// error response codes indicate failure
response_code <= 199 || response_code >= 300) {
writeAlerts({
account_sid,
alert_type: AlertType.TTS_FAILURE,
vendor,
detail: evt.variable_tts_error || `TTS playback failed with response code ${response_code}`,
target_sid
}).catch((err) => this.logger.info({err}, 'Error generating alert for no tts'));
}
if (
!tts_error &&
//2xx response codes indicate success
199 < response_code && response_code < 300 &&
evt.variable_tts_cache_filename &&
!this.killed &&
// if tts cache is not disabled, add the file to cache
!this.disableTtsCache
) {
const text = parseTextFromSayString(this.text[segment]);
this.logger.debug({text, cacheFile: evt.variable_tts_cache_filename}, 'Say:exec cache tts');
addFileToCache(evt.variable_tts_cache_filename, {
account_sid,
vendor,
language,
voice,
engine,
model: this.model || this.model_id,
text,
instructions: this.instructions
}).catch((err) => this.logger.info({err}, 'Error adding file to cache'));
}
if (this._playResolve) {
(tts_error ||
// error response codes indicate failure
response_code <= 199 || response_code >= 300
) ?
this._playReject(
new Error(evt.variable_tts_error || `TTS playback failed with response code ${response_code}`)
) : this._playResolve();
}
} catch (err) {
this.logger.info({err}, 'Error handling playback-stop event');
}
});
};
this._boundOnPlaybackStop = onPlaybackStop.bind(this);
const onPlaybackStart = (evt) => {
try {
const playbackId = this.getPlaybackId(segment);
const isMatch = isMatchingEvent(this.logger, filename, playbackId, evt);
if (!isMatch) {
this.logger.info({currentPlaybackId: playbackId, startPlaybackId: evt.variable_tts_playback_id},
'Say:exec playback-start - unmatched playback_id');
ep.once('playback-start', this._boundOnPlaybackStart);
return;
}
ep.once('playback-stop', this._boundOnPlaybackStop);
this.logger.debug({evt},
`Say got playback-start ${evt.variable_tts_playback_id ? evt.variable_tts_playback_id : ''}`);
if (this.otelSpan) {
this._addStreamingTtsAttributes(this.otelSpan, evt, vendor);
this.otelSpan.end();
this.otelSpan = null;
if (evt.variable_tts_cache_filename) {
cs.trackTmpFile(evt.variable_tts_cache_filename);
}
}
} catch (err) {
this.logger.info({err}, 'Error handling playback-start event');
}
};
this._boundOnPlaybackStart = onPlaybackStart.bind(this);
ep.once('playback-start', this._boundOnPlaybackStart);
// wait for playback-stop event received to confirm if the playback is successful
this._playPromise = new Promise((resolve, reject) => {
this._playResolve = resolve;
this._playReject = reject;
});
const r = await ep.play(filepath[segment]);
this.logger.debug({r}, 'Say:exec play result');
try {
const r = await ep.play(filename);
this.logger.debug({r}, 'Say:exec play result');
if (r.playbackSeconds == null && r.playbackMilliseconds == null && r.playbackLastOffsetPos == null) {
this._playReject(new Error('Playback failed to start'));
}
} catch (err) {
if (NON_FANTAL_ERRORS.includes(err.message)) {
throw new NonFatalTaskError(err.message);
}
throw err;
}
try {
// wait for playback-stop event received to confirm if the playback is successful
await this._playPromise;
@@ -272,12 +432,12 @@ class TaskSay extends TtsTask {
this._playResolve = null;
this._playReject = null;
}
if (filepath[segment].startsWith('say:{')) {
const arr = /^say:\{.*\}\s*(.*)$/.exec(filepath[segment]);
if (filename.startsWith('say:{')) {
const arr = /^say:\{.*\}\s*(.*)$/.exec(filename);
if (arr) this.logger.debug(`Say:exec complete playing streaming tts request: ${arr[1].substring(0, 64)}..`);
} else {
// This log will print spech credentials in say command for tts stream mode
this.logger.debug(`Say:exec completed play file ${filepath[segment]}`);
this.logger.debug(`Say:exec completed play file ${filename}`);
}
}
segment++;
@@ -292,8 +452,13 @@ class TaskSay extends TtsTask {
if (cs.isInConference) {
const {memberId, confName} = cs;
this.killPlayToConfMember(this.ep, memberId, confName);
}
else {
} else if (this.isStreamingTts) {
this.logger.debug('TaskSay:kill - stopping TTS stream for streaming audio');
cs.stopTtsStream();
} else {
if (!this.notifiedPlayBackStop) {
this.notifyStatus({event: 'stop-playback'});
}
this.notifyStatus({event: 'kill-playback'});
this.ep.api('uuid_break', this.ep.uuid);
}
@@ -314,10 +479,13 @@ class TaskSay extends TtsTask {
if (key.startsWith('variable_tts_')) {
let newKey = key.substring('variable_tts_'.length)
.replace('whisper_', 'whisper.')
.replace('nvidia_', 'nvidia.')
.replace('deepgram_', 'deepgram.')
.replace('playht_', 'playht.')
.replace('cartesia_', 'cartesia.')
.replace('rimelabs_', 'rimelabs.')
.replace('resemble_', 'resemble.')
.replace('inworld_', 'inworld.')
.replace('verbio_', 'verbio.')
.replace('elevenlabs_', 'elevenlabs.');
if (spanMapping[newKey]) newKey = spanMapping[newKey];
@@ -382,6 +550,14 @@ const spanMapping = {
'rimelabs.name_lookup_time_ms': 'name_lookup_ms',
'rimelabs.connect_time_ms': 'connect_ms',
'rimelabs.final_response_time_ms': 'final_response_ms',
// Resemble
'resemble.connect_time_ms': 'connect_ms',
'resemble.final_response_time_ms': 'final_response_ms',
// inworld
'inworld.name_lookup_time_ms': 'name_lookup_ms',
'inworld.connect_time_ms': 'connect_ms',
'inworld.final_response_time_ms': 'final_response_ms',
'inworld.x_envoy_upstream_service_time': 'upstream_service_time',
// verbio
'verbio.name_lookup_time_ms': 'name_lookup_ms',
'verbio.connect_time_ms': 'connect_ms',

View File

@@ -18,6 +18,11 @@ class TaskSipDecline extends Task {
super.exec(cs);
res.send(this.data.status, this.data.reason, {
headers: this.headers
}, (err) => {
if (!err) {
// Call was successfully declined
cs._callReleased();
}
});
cs.emit('callStatusChange', {
callStatus: CallStatus.Failed,

View File

@@ -111,7 +111,12 @@ class TaskSipRefer extends Task {
/* get IP address of the SBC to use as hostname if needed */
const {host} = parseUri(dlg.remote.uri);
if (!referTo.startsWith('<') && !referTo.startsWith('sip') && !referTo.startsWith('"')) {
if (
!referTo.startsWith('<') &&
!referTo.startsWith('sip:') &&
!referTo.startsWith('"') &&
!referTo.startsWith('tel:')
) {
/* they may have only provided a phone number/user */
referTo = `sip:${referTo}@${host}`;
}
@@ -124,7 +129,12 @@ class TaskSipRefer extends Task {
if (!referredByDisplayName) {
referredByDisplayName = cs.req?.callingName;
}
if (!referredBy.startsWith('<') && !referredBy.startsWith('sip') && !referredBy.startsWith('"')) {
if (
!referredBy.startsWith('<') &&
!referredBy.startsWith('sip:') &&
!referredBy.startsWith('"') &&
!referredBy.startsWith('tel:')
) {
/* they may have only provided a phone number/user */
referredBy = `${referredByDisplayName ? `"${referredByDisplayName}"` : ''}<sip:${referredBy}@${host}>`;
}

View File

@@ -4,6 +4,31 @@ const crypto = require('crypto');
const { TaskPreconditions, CobaltTranscriptionEvents } = require('../utils/constants');
const { SpeechCredentialError } = require('../utils/error');
const {JAMBONES_AWS_TRANSCRIBE_USE_GRPC} = require('../config');
const {TaskName} = require('../utils/constants.json');
/**
* "Please insert turns here: {{turns:4}}"
// -> { processed: 'Please insert turns here: {{turns}}', turns: 4 }
processTurnString("Please insert turns here: {{turns}}"));
// -> { processed: 'Please insert turns here: {{turns}}', turns: null }
*/
const processTurnString = (input) => {
const regex = /\{\{turns(?::(\d+))?\}\}/;
const match = input.match(regex);
if (!match) {
return {
processed: input,
turns: null
};
}
const turns = match[1] ? parseInt(match[1], 10) : null;
const processed = input.replace(regex, '{{turns}}');
return { processed, turns };
};
class SttTask extends Task {
@@ -60,6 +85,9 @@ class SttTask extends Task {
/*bug name prefix */
this.bugname_prefix = '';
// stt latency calculator
this.stt_latency_ms = '';
}
async exec(cs, {ep, ep2}) {
@@ -67,6 +95,12 @@ class SttTask extends Task {
this.ep = ep;
this.ep2 = ep2;
// start vad from stt latency calculator
if (this.name !== TaskName.Gather ||
this.name === TaskName.Gather && this.needsStt) {
cs.startSttLatencyVad();
}
// use session preferences if we don't have specific verb-level settings.
if (cs.recognizer) {
for (const k in cs.recognizer) {
@@ -169,6 +203,56 @@ class SttTask extends Task {
if (cs.hasGlobalSttPunctuation && !this.data.recognizer.punctuation) {
this.data.recognizer.punctuation = cs.globalSttPunctuation;
}
if (this.vendor === 'gladia') {
const { api_key, region } = this.sttCredentials;
const {url} = await this.createGladiaLiveSession({
api_key, region,
model: this.data.recognizer.model || 'solaria-1',
options: this.data.recognizer.gladiaOptions || {}
});
const {host, pathname, search} = new URL(url);
this.sttCredentials.host = host;
this.sttCredentials.path = `${pathname}${search}`;
}
}
async createGladiaLiveSession({
api_key,
region = 'us-west',
model = 'solaria-1',
options = {},
}) {
const url = `https://api.gladia.io/v2/live?region=${region}`;
const response = await fetch(url, {
method: 'POST',
headers: {
'x-gladia-key': api_key,
'Content-Type': 'application/json'
},
body: JSON.stringify({
encoding: 'wav/pcm',
bit_depth: 16,
sample_rate: 8000,
channels: 1,
model,
...options,
messages_config: {
receive_final_transcripts: true,
receive_speech_events: true,
receive_errors: true,
}
})
});
if (!response.ok) {
const error = await response.text();
this.logger.error({url, status: response.status, error}, 'Error creating Gladia live session');
throw new Error(`Error creating Gladia live session: ${response.status} ${error}`);
}
const data = await response.json();
this.logger.debug({url: data.url}, 'Gladia Call registered');
return data;
}
addCustomEventListener(ep, event, handler) {
@@ -290,6 +374,57 @@ class SttTask extends Task {
});
}
formatOpenAIPrompt(cs, {prompt, hintsTemplate, conversationHistoryTemplate, hints}) {
let conversationHistoryPrompt, hintsPrompt;
/* generate conversation history from template */
if (conversationHistoryTemplate) {
const {processed, turns} = processTurnString(conversationHistoryTemplate);
this.logger.debug({processed, turns}, 'SttTask: processed conversation history template');
conversationHistoryPrompt = cs.getFormattedConversation(turns || 4);
//this.logger.debug({conversationHistoryPrompt}, 'SttTask: conversation history');
if (conversationHistoryPrompt) {
conversationHistoryPrompt = processed.replace('{{turns}}', `\n${conversationHistoryPrompt}\nuser: `);
}
}
/* generate hints from template */
if (hintsTemplate && Array.isArray(hints) && hints.length > 0) {
hintsPrompt = hintsTemplate.replace('{{hints}}', hints);
}
/* combine into final prompt */
let finalPrompt = prompt || '';
if (hintsPrompt) {
finalPrompt = `${finalPrompt}\n${hintsPrompt}`;
}
if (conversationHistoryPrompt) {
finalPrompt = `${finalPrompt}\n${conversationHistoryPrompt}`;
}
this.logger.debug({
finalPrompt,
hints,
hintsPrompt,
conversationHistoryTemplate,
conversationHistoryPrompt
}, 'SttTask: formatted OpenAI prompt');
return finalPrompt?.trimStart();
}
/* some STT engines will keep listening after a final response, so no need to restart */
doesVendorContinueListeningAfterFinalTranscript(vendor) {
return (vendor.startsWith('custom:') || [
'soniox',
'aws',
'microsoft',
'deepgram',
'google',
'speechmatics',
'openai',
].includes(vendor));
}
_onCompileContext(ep, key, evt) {
const {addKey} = this.cs.srf.locals.dbHelpers;
this.logger.debug({evt}, `received cobalt compile context event, will cache under ${key}`);
@@ -325,7 +460,7 @@ class SttTask extends Task {
dgOptions.utteranceEndMs = dgOptions.utteranceEndMs || asrTimeout;
}
_onVendorConnect(_cs, _ep) {
_onVendorConnect(cs, _ep) {
this.logger.debug(`TaskGather:_on${this.vendor}Connect`);
}

View File

@@ -1,5 +1,5 @@
const Emitter = require('events');
const uuidv4 = require('uuid-random');
const crypto = require('crypto');
const {TaskPreconditions} = require('../utils/constants');
const { normalizeJambones } = require('@jambonz/verb-specifications');
const WsRequestor = require('../utils/ws-requestor');
@@ -19,6 +19,7 @@ class Task extends Emitter {
this.data = data;
this.actionHook = this.data.actionHook;
this.id = data.id;
this.taskId = crypto.randomUUID();
this._killInProgress = false;
this._completionPromise = new Promise((resolve) => this._completionResolver = resolve);
@@ -165,7 +166,7 @@ class Task extends Emitter {
span.setAttributes({'http.body': JSON.stringify(params)});
try {
if (this.id) params.verb_id = this.id;
const json = await this.cs.requestor.request(type, this.actionHook, params, httpHeaders);
const json = await this.cs.requestor.request(type, this.actionHook, params, httpHeaders, span);
span.setAttributes({'http.statusCode': 200});
const isWsConnection = this.cs.requestor instanceof WsRequestor;
if (!isWsConnection || (expectResponse && json && Array.isArray(json) && json.length)) {
@@ -209,7 +210,7 @@ class Task extends Emitter {
const httpHeaders = b3 && {b3};
span.setAttributes({'http.body': JSON.stringify(params)});
try {
const json = await cs.requestor.request('verb:hook', hook, params, httpHeaders);
const json = await cs.requestor.request('verb:hook', hook, params, httpHeaders, span);
span.setAttributes({'http.statusCode': 200});
span.end();
if (json && Array.isArray(json)) {
@@ -272,7 +273,7 @@ class Task extends Emitter {
}
async transferCallToFeatureServer(cs, sipAddress, opts) {
const uuid = uuidv4();
const uuid = crypto.randomUUID();
const {addKey} = cs.srf.locals.dbHelpers;
const obj = Object.assign({}, cs.application);
delete obj.requestor;

View File

@@ -6,6 +6,8 @@ const {
AwsTranscriptionEvents,
AzureTranscriptionEvents,
DeepgramTranscriptionEvents,
GladiaTranscriptionEvents,
DeepgramfluxTranscriptionEvents,
SonioxTranscriptionEvents,
CobaltTranscriptionEvents,
IbmTranscriptionEvents,
@@ -13,7 +15,10 @@ const {
JambonzTranscriptionEvents,
TranscribeStatus,
AssemblyAiTranscriptionEvents,
HoundifyTranscriptionEvents,
VoxistTranscriptionEvents,
CartesiaTranscriptionEvents,
OpenAITranscriptionEvents,
VerbioTranscriptionEvents,
SpeechmaticsTranscriptionEvents
} = require('../utils/constants.json');
@@ -30,7 +35,6 @@ class TaskTranscribe extends SttTask {
this.transcriptionHook = this.data.transcriptionHook;
this.translationHook = this.data.translationHook;
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
if (this.data.recognizer) {
this.interim = !!this.data.recognizer.interim;
this.separateRecognitionPerChannel = this.data.recognizer.separateRecognitionPerChannel;
@@ -105,7 +109,7 @@ class TaskTranscribe extends SttTask {
if (cs.hasGlobalSttHints) {
const {hints, hintsBoost} = cs.globalSttHints;
this.data.recognizer.hints = this.data.recognizer.hints.concat(hints);
this.data.recognizer.hints = this.data.recognizer?.hints?.concat(hints);
if (!this.data.recognizer.hintsBoost && hintsBoost) this.data.recognizer.hintsBoost = hintsBoost;
this.logger.debug({hints: this.data.recognizer.hints, hintsBoost: this.data.recognizer.hintsBoost},
'Transcribe:exec - applying global sttHints');
@@ -136,22 +140,30 @@ class TaskTranscribe extends SttTask {
stopTranscription = true;
this.ep.stopTranscription({
vendor: this.vendor,
bugname: this.bugname
bugname: this.bugname,
gracefulShutdown: this.paused ? false : true
})
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
}
if (this.transcribing2 && this.ep2?.connected) {
stopTranscription = true;
this.ep2.stopTranscription({vendor: this.vendor, bugname: this.bugname})
this.ep2.stopTranscription({
vendor: this.vendor,
bugname: this.bugname,
gracefulShutdown: this.paused ? false : true
})
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
}
this.cs.emit('transcribe-stop');
return stopTranscription;
}
async kill(cs) {
super.kill(cs);
const stopTranscription = this._stopTranscription();
cs.stopSttLatencyVad();
// hangup after 1 sec if we don't get a final transcription
if (stopTranscription) this._timer = setTimeout(() => this.notifyTaskDone(), 1500);
else this.notifyTaskDone();
@@ -227,10 +239,35 @@ class TaskTranscribe extends SttTask {
this._onVendorConnect.bind(this, cs, ep));
this.addCustomEventListener(ep, DeepgramTranscriptionEvents.ConnectFailure,
this._onVendorConnectFailure.bind(this, cs, ep, channel));
this.addCustomEventListener(ep, DeepgramTranscriptionEvents.Error, this._onVendorError.bind(this, cs, ep));
/* if app sets deepgramOptions.utteranceEndMs they essentially want continuous asr */
//if (opts.DEEPGRAM_SPEECH_UTTERANCE_END_MS) this.isContinuousAsr = true;
break;
case 'deepgramflux':
this.bugname = `${this.bugname_prefix}deepgramflux_transcribe`;
this.addCustomEventListener(ep, DeepgramfluxTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep, channel));
this.addCustomEventListener(ep, DeepgramfluxTranscriptionEvents.Connect,
this._onVendorConnect.bind(this, cs, ep));
this.addCustomEventListener(ep, DeepgramfluxTranscriptionEvents.ConnectFailure,
this._onVendorConnectFailure.bind(this, cs, ep, channel));
this.addCustomEventListener(ep, DeepgramfluxTranscriptionEvents.Error, this._onVendorError.bind(this, cs, ep));
break;
case 'gladia':
this.bugname = `${this.bugname_prefix}gladia_transcribe`;
this.addCustomEventListener(ep, GladiaTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep, channel));
this.addCustomEventListener(ep, GladiaTranscriptionEvents.Connect,
this._onVendorConnect.bind(this, cs, ep));
this.addCustomEventListener(ep, GladiaTranscriptionEvents.ConnectFailure,
this._onVendorConnectFailure.bind(this, cs, ep, channel));
this.addCustomEventListener(ep, GladiaTranscriptionEvents.Error, this._onVendorError.bind(this, cs, ep));
break;
case 'soniox':
this.bugname = `${this.bugname_prefix}soniox_transcribe`;
@@ -301,6 +338,18 @@ class TaskTranscribe extends SttTask {
this._onVendorConnectFailure.bind(this, cs, ep, channel));
break;
case 'houndify':
this.bugname = `${this.bugname_prefix}houndify_transcribe`;
this.addCustomEventListener(ep, HoundifyTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep, channel));
this.addCustomEventListener(ep, HoundifyTranscriptionEvents.Error,
this._onVendorError.bind(this, cs, ep));
this.addCustomEventListener(ep, HoundifyTranscriptionEvents.ConnectFailure,
this._onVendorConnectFailure.bind(this, cs, ep, channel));
this.addCustomEventListener(ep, HoundifyTranscriptionEvents.Connect,
this._onVendorConnect.bind(this, cs, ep));
break;
case 'voxist':
this.bugname = `${this.bugname_prefix}voxist_transcribe`;
this.addCustomEventListener(ep, VoxistTranscriptionEvents.Transcription,
@@ -312,6 +361,17 @@ class TaskTranscribe extends SttTask {
this._onVendorConnectFailure.bind(this, cs, ep, channel));
break;
case 'cartesia':
this.bugname = `${this.bugname_prefix}cartesia_transcribe`;
this.addCustomEventListener(ep, CartesiaTranscriptionEvents.Transcription,
this._onTranscription.bind(this, cs, ep, channel));
this.addCustomEventListener(ep,
CartesiaTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
this.addCustomEventListener(ep, CartesiaTranscriptionEvents.Error, this._onVendorError.bind(this, cs, ep));
this.addCustomEventListener(ep, CartesiaTranscriptionEvents.ConnectFailure,
this._onVendorConnectFailure.bind(this, cs, ep, channel));
break;
case 'speechmatics':
this.bugname = `${this.bugname_prefix}speechmatics_transcribe`;
this.addCustomEventListener(
@@ -330,6 +390,20 @@ class TaskTranscribe extends SttTask {
this._onSpeechmaticsError.bind(this, cs, ep));
break;
case 'openai':
this.bugname = `${this.bugname_prefix}openai_transcribe`;
this.addCustomEventListener(
ep, OpenAITranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep, channel));
this.addCustomEventListener(ep, OpenAITranscriptionEvents.Connect,
this._onVendorConnect.bind(this, cs, ep));
this.addCustomEventListener(ep, OpenAITranscriptionEvents.ConnectFailure,
this._onVendorConnectFailure.bind(this, cs, ep));
this.addCustomEventListener(ep, OpenAITranscriptionEvents.Error,
this._onOpenAIErrror.bind(this, cs, ep));
this.modelSupportsConversationTracking = opts.OPENAI_MODEL !== 'whisper-1';
break;
default:
if (this.vendor.startsWith('custom:')) {
this.bugname = `${this.bugname_prefix}${this.vendor}_transcribe`;
@@ -365,6 +439,25 @@ class TaskTranscribe extends SttTask {
async _transcribe(ep) {
this.logger.debug(
`TaskTranscribe:_transcribe - starting transcription vendor ${this.vendor} bugname ${this.bugname}`);
/* special feature for openai: we can provide a prompt that includes recent conversation history */
let prompt;
if (this.vendor === 'openai') {
if (this.modelSupportsConversationTracking) {
prompt = this.formatOpenAIPrompt(this.cs, {
prompt: this.data.recognizer?.openaiOptions?.prompt,
hintsTemplate: this.data.recognizer?.openaiOptions?.promptTemplates?.hintsTemplate,
// eslint-disable-next-line max-len
conversationHistoryTemplate: this.data.recognizer?.openaiOptions?.promptTemplates?.conversationHistoryTemplate,
hints: this.data.recognizer?.hints,
});
this.logger.debug({prompt}, 'Gather:_startTranscribing - created an openai prompt');
}
else if (this.data.recognizer?.hints?.length > 0) {
prompt = this.data.recognizer?.hints.join(', ');
}
}
await ep.startTranscription({
vendor: this.vendor,
interim: this.interim ? true : false,
@@ -373,9 +466,16 @@ class TaskTranscribe extends SttTask {
bugname: this.bugname,
hostport: this.hostport
});
// Some vendor use single connection, that we cannot use onConnect event to track transcription start
this.cs.emit('transcribe-start');
}
async _onTranscription(cs, ep, channel, evt, fsEvent) {
// check if we are in graceful shutdown mode
if (ep.gracefulShutdownResolver) {
ep.gracefulShutdownResolver();
}
// make sure this is not a transcript from answering machine detection
const bugname = fsEvent.getHeader('media-bugname');
const finished = fsEvent.getHeader('transcription-session-finished');
@@ -387,6 +487,9 @@ class TaskTranscribe extends SttTask {
if (this.vendor === 'ibm' && evt?.state === 'listening') return;
// emit an event to the call session to track the time transcription is received
cs.emit('on-transcription');
if (this.vendor === 'deepgram' && evt.type === 'UtteranceEnd') {
/* we will only get this when we have set utterance_end_ms */
@@ -456,8 +559,9 @@ class TaskTranscribe extends SttTask {
this._startAsrTimer(channel);
/* some STT engines will keep listening after a final response, so no need to restart */
if (!['soniox', 'aws', 'microsoft', 'deepgram', 'google', 'speechmatics']
.includes(this.vendor)) this._startTranscribing(cs, ep, channel);
if (!this.doesVendorContinueListeningAfterFinalTranscript(this.vendor)) {
this._startTranscribing(cs, ep, channel);
}
}
else {
if (this.vendor === 'soniox') {
@@ -480,9 +584,7 @@ class TaskTranscribe extends SttTask {
this.logger.debug({evt}, 'TaskTranscribe:_onTranscription - sending final transcript');
this._resolve(channel, evt);
/* some STT engines will keep listening after a final response, so no need to restart */
if (!['soniox', 'aws', 'microsoft', 'deepgram', 'google', 'speechmatics'].includes(this.vendor) &&
!this.vendor.startsWith('custom:')) {
if (!this.doesVendorContinueListeningAfterFinalTranscript(this.vendor)) {
this.logger.debug('TaskTranscribe:_onTranscription - restarting transcribe');
this._startTranscribing(cs, ep, channel);
}
@@ -549,14 +651,28 @@ class TaskTranscribe extends SttTask {
}
async _resolve(channel, evt) {
let sttLatencyMetrics = {};
if (evt.is_final) {
const sttLatency = this.cs.calculateSttLatency();
if (sttLatency) {
sttLatencyMetrics = {
'stt.latency_ms': `${sttLatency.stt_latency_ms}`,
'stt.talkspurts': JSON.stringify(sttLatency.talkspurts),
'stt.start_time': sttLatency.stt_start_time,
'stt.stop_time': sttLatency.stt_stop_time,
'stt.usage': sttLatency.stt_usage,
};
}
// time to reset the stt latency
this.cs.emit('transcribe-start');
/* we've got a final 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.label': this.label || 'None',
'stt.resolve': 'transcript',
'stt.result': JSON.stringify(evt)
'stt.result': JSON.stringify(evt),
...sttLatencyMetrics
});
this.childSpan[channel - 1].span.end();
}
@@ -565,9 +681,13 @@ class TaskTranscribe extends SttTask {
if (this.transcriptionHook) {
const b3 = this.getTracingPropagation();
const httpHeaders = b3 && {b3};
const latencies = Object.fromEntries(
Object.entries(sttLatencyMetrics).map(([key, value]) => [key.replace('stt.', 'stt_'), value])
);
const payload = {
...this.cs.callInfo,
...httpHeaders,
...latencies,
...(evt.alternatives && {speech: evt}),
...(evt.type && {speechEvent: evt})
};
@@ -621,12 +741,21 @@ class TaskTranscribe extends SttTask {
}
_onMaxDurationExceeded(cs, ep, channel) {
this.logger.debug(`TaskTranscribe:_onMaxDurationExceeded on channel ${channel}`);
this.restartDueToError(ep, channel, 'Max duration exceeded');
}
_onMaxBufferExceeded(cs, ep, channel) {
this.restartDueToError(ep, channel, 'Max buffer exceeded');
}
restartDueToError(ep, channel, reason) {
this.logger.debug(`TaskTranscribe:${reason} on channel ${channel}`);
if (this.paused) return;
if (this.childSpan[channel - 1] && this.childSpan[channel - 1].span) {
this.childSpan[channel - 1].span.setAttributes({
channel,
'stt.resolve': 'max duration exceeded',
'stt.resolve': reason,
'stt.label': this.label || 'None',
});
this.childSpan[channel - 1].span.end();
@@ -650,7 +779,8 @@ class TaskTranscribe extends SttTask {
if (this.canFallback) {
_ep.stopTranscription({
vendor: this.vendor,
bugname: this.bugname
bugname: this.bugname,
gracefulShutdown: false
})
.catch((err) => this.logger.error({err}, `Error stopping transcription for primary vendor ${this.vendor}`));
try {
@@ -683,6 +813,14 @@ class TaskTranscribe extends SttTask {
return;
}
this.logger.info({evt}, 'TaskTranscribe:_onJambonzError');
if (this.vendor === 'microsoft' &&
evt.error?.includes('Due to service inactivity, the client buffer exceeded maximum size. Resetting the buffer')) {
let channel = 1;
if (this.ep !== _ep) {
channel = 2;
}
return this._onMaxBufferExceeded(cs, _ep, channel);
}
if (this.paused) return;
const {writeAlerts, AlertType} = cs.srf.locals;
@@ -733,6 +871,12 @@ class TaskTranscribe extends SttTask {
this._onVendorError(cs, _ep, {error: JSON.stringify(e)});
}
async _onOpenAIErrror(cs, _ep, evt) {
// eslint-disable-next-line no-unused-vars
const {message, ...e} = evt;
this._onVendorError(cs, _ep, {error: JSON.stringify(e)});
}
_startAsrTimer(channel) {
if (this.vendor === 'deepgram') return; // no need
assert(this.isContinuousAsr);

View File

@@ -3,6 +3,16 @@ const { TaskPreconditions } = require('../utils/constants');
const { SpeechCredentialError } = require('../utils/error');
const dbUtils = require('../utils/db-utils');
const extractPlaybackId = (str) => {
// Match say:{...} and capture the content inside braces
const match = str.match(/say:\{([^}]*)\}/);
if (!match) return null;
// Look for playback_id=value within the captured content
const playbackMatch = match[1].match(/playback_id=([^,]*)/);
return playbackMatch ? playbackMatch[1] : null;
};
class TtsTask extends Task {
constructor(logger, data, parentTask) {
@@ -21,10 +31,20 @@ class TtsTask extends Task {
this.synthesizer = this.data.synthesizer || {};
this.disableTtsCache = this.data.disableTtsCache;
this.options = this.synthesizer.options || {};
this.instructions = this.data.instructions;
this.playbackIds = [];
}
getPlaybackId(offset) {
return this.playbackIds[offset];
}
async exec(cs) {
super.exec(cs);
// update disableTtsCache from call session if not set in task
if (this.data.disableTtsCache == null) {
this.disableTtsCache = cs.disableTtsCache;
}
if (cs.synthesizer) {
this.options = {...cs.synthesizer.options, ...this.options};
this.data.synthesizer = this.data.synthesizer || {};
@@ -42,6 +62,12 @@ class TtsTask extends Task {
}
}
}
const fullText = Array.isArray(this.text) ? this.text.join(' ') : this.text;
// in case dub verb, text might not be set.
if (fullText?.length > 0) {
cs.emit('botSaid', fullText);
}
}
getTtsVendorData(cs) {
@@ -59,48 +85,67 @@ class TtsTask extends Task {
}
async setTtsStreamingChannelVars(vendor, language, voice, credentials, ep) {
const {api_key, model_id, custom_tts_streaming_url, auth_token} = credentials;
const {stability, similarity_boost, use_speaker_boost, style} = this.options;
let obj;
const {api_key, model_id, api_uri, custom_tts_streaming_url, auth_token, options} = credentials;
// api_key, model_id, api_uri, custom_tts_streaming_url, and auth_token are encoded in the credentials
// allow them to be overriden via config, using options
// give preference to options passed in via config
const parsed_options = JSON.parse(options ?? '{}');
const local_options = {...parsed_options, ...this.options ?? {}};
const local_voice_settings = {...parsed_options.voice_settings ?? {}, ...this.options?.voice_settings ?? {}};
const local_api_key = local_options.api_key ?? api_key;
const local_model_id = local_options.model_id ?? model_id;
const local_api_uri = local_options.api_uri ?? api_uri;
const local_custom_tts_streaming_url = local_options.custom_tts_streaming_url ?? custom_tts_streaming_url;
const local_auth_token = local_options.auth_token ?? auth_token;
this.logger.debug({credentials},
`setTtsStreamingChannelVars: vendor: ${vendor}, language: ${language}, voice: ${voice}`);
let obj;
switch (vendor) {
case 'deepgram':
obj = {
DEEPGRAM_API_KEY: api_key,
DEEPGRAM_API_KEY: local_api_key,
DEEPGRAM_TTS_STREAMING_MODEL: voice
};
break;
case 'cartesia':
obj = {
CARTESIA_API_KEY: api_key,
CARTESIA_TTS_STREAMING_MODEL_ID: model_id,
CARTESIA_API_KEY: local_api_key,
CARTESIA_TTS_STREAMING_MODEL_ID: local_model_id,
CARTESIA_TTS_STREAMING_VOICE_ID: voice,
CARTESIA_TTS_STREAMING_LANGUAGE: language || 'en',
};
break;
case 'elevenlabs':
// eslint-disable-next-line max-len
const {stability, similarity_boost, use_speaker_boost, style, speed} = local_voice_settings || {};
obj = {
ELEVENLABS_API_KEY: api_key,
ELEVENLABS_TTS_STREAMING_MODEL_ID: model_id,
ELEVENLABS_API_KEY: local_api_key,
...(api_uri && {ELEVENLABS_API_URI: local_api_uri}),
ELEVENLABS_TTS_STREAMING_MODEL_ID: local_model_id,
ELEVENLABS_TTS_STREAMING_VOICE_ID: voice,
// 20/12/2024 - only eleven_turbo_v2_5 support multiple language
...(['eleven_turbo_v2_5'].includes(model_id) && {ELEVENLABS_TTS_STREAMING_LANGUAGE: language}),
...(['eleven_turbo_v2_5'].includes(local_model_id) && {ELEVENLABS_TTS_STREAMING_LANGUAGE: language}),
...(stability && {ELEVENLABS_TTS_STREAMING_VOICE_SETTINGS_STABILITY: stability}),
...(similarity_boost && {ELEVENLABS_TTS_STREAMING_VOICE_SETTINGS_SIMILARITY_BOOST: similarity_boost}),
...(use_speaker_boost && {ELEVENLABS_TTS_STREAMING_VOICE_SETTINGS_USE_SPEAKER_BOOST: use_speaker_boost}),
...(style && {ELEVENLABS_TTS_STREAMING_VOICE_SETTINGS_STYLE: style})
...(style && {ELEVENLABS_TTS_STREAMING_VOICE_SETTINGS_STYLE: style}),
// speed has value 0.7 to 1.2, 1.0 is default, make sure we send the value event it's 0
...(speed !== null && speed !== undefined && {ELEVENLABS_TTS_STREAMING_VOICE_SETTINGS_SPEED: `${speed}`}),
...(local_options.pronunciation_dictionary_locators &&
Array.isArray(local_options.pronunciation_dictionary_locators) && {
ELEVENLABS_TTS_STREAMING_PRONUNCIATION_DICTIONARY_LOCATORS:
JSON.stringify(local_options.pronunciation_dictionary_locators)
}),
};
break;
case 'rimelabs':
const {
pauseBetweenBrackets, phonemizeBetweenBrackets, inlineSpeedAlpha, speedAlpha, reduceLatency
} = this.options;
} = local_options;
obj = {
RIMELABS_API_KEY: api_key,
RIMELABS_TTS_STREAMING_MODEL_ID: model_id,
RIMELABS_API_KEY: local_api_key,
RIMELABS_TTS_STREAMING_MODEL_ID: local_model_id,
RIMELABS_TTS_STREAMING_VOICE_ID: voice,
RIMELABS_TTS_STREAMING_LANGUAGE: language || 'en',
...(pauseBetweenBrackets && {RIMELABS_TTS_STREAMING_PAUSE_BETWEEN_BRACKETS: pauseBetweenBrackets}),
@@ -115,8 +160,8 @@ class TtsTask extends Task {
if (vendor.startsWith('custom:')) {
const use_tls = custom_tts_streaming_url.startsWith('wss://');
obj = {
CUSTOM_TTS_STREAMING_HOST: custom_tts_streaming_url.replace(/^(ws|wss):\/\//, ''),
CUSTOM_TTS_STREAMING_API_KEY: auth_token,
CUSTOM_TTS_STREAMING_HOST: local_custom_tts_streaming_url.replace(/^(ws|wss):\/\//, ''),
CUSTOM_TTS_STREAMING_API_KEY: local_auth_token,
CUSTOM_TTS_STREAMING_VOICE_ID: voice,
CUSTOM_TTS_STREAMING_LANGUAGE: language || 'en',
CUSTOM_TTS_STREAMING_USE_TLS: use_tls
@@ -125,7 +170,7 @@ class TtsTask extends Task {
throw new Error(`vendor ${vendor} is not supported for tts streaming yet`);
}
}
this.logger.info({vendor, credentials, obj}, 'setTtsStreamingChannelVars');
this.logger.debug({vendor, credentials, obj}, 'setTtsStreamingChannelVars');
await ep.set(obj);
}
@@ -152,7 +197,6 @@ class TtsTask extends Task {
} else if (vendor === 'deepgram') {
this.model = voice;
}
this.model_id = credentials.model_id;
/* allow for microsoft custom region voice and api_key to be specified as an override */
if (vendor === 'microsoft' && this.options.deploymentId) {
@@ -172,6 +216,9 @@ class TtsTask extends Task {
} else if (vendor === 'rimelabs') {
credentials = credentials || {};
credentials.model_id = this.options.model_id || credentials.model_id;
} else if (vendor === 'inworld') {
credentials = credentials || {};
credentials.model_id = this.options.model_id || credentials.model_id;
} else if (vendor === 'whisper') {
credentials = credentials || {};
credentials.model_id = this.options.model_id || credentials.model_id;
@@ -193,8 +240,12 @@ class TtsTask extends Task {
};
}
}
} else if (vendor === 'cartesia') {
credentials.model_id = this.options.model_id || credentials.model_id;
}
this.model_id = credentials.model_id;
/**
* note on cache_speech_handles. This was found to be risky.
* It can cause a crash in the following sequence on a single call:
@@ -216,7 +267,7 @@ class TtsTask extends Task {
//cs.currentTtsVendor = vendor;
if (!preCache && !this._disableTracing)
this.logger.info({vendor, language, voice, model: this.model}, 'TaskSay:exec');
this.logger.debug({vendor, language, voice, model: this.model}, 'TaskSay:exec');
try {
if (!credentials) {
writeAlerts({
@@ -229,9 +280,9 @@ class TtsTask extends Task {
}
/* produce an audio segment from the provided text */
const generateAudio = async(text) => {
if (this.killed) return;
if (text.startsWith('silence_stream://')) return text;
const generateAudio = async(text, index) => {
if (this.killed) return {index, filePath: null};
if (text.startsWith('silence_stream://')) return {index, filePath: text};
/* otel: trace time for tts */
if (!preCache && !this._disableTracing) {
@@ -247,6 +298,7 @@ class TtsTask extends Task {
const {filePath, servedFromCache, rtt} = await synthAudio(stats, {
account_sid,
text,
instructions: this.instructions,
vendor,
language,
voice,
@@ -272,16 +324,35 @@ class TtsTask extends Task {
vendor,
language,
characters: text.length,
elapsedTime: rtt
elapsedTime: rtt,
servedFromCache,
'id': this.id
});
}
if (servedFromCache) {
this.notifyStatus({
event: 'synthesized-audio',
vendor,
language,
servedFromCache,
'id': this.id
});
}
return {index, filePath, playbackId: null};
}
else {
const playbackId = extractPlaybackId(filePath);
this.logger.debug('Say: a streaming tts api will be used');
const modifiedPath = filePath.replace('say:{', `say:{session-uuid=${ep.uuid},`);
return modifiedPath;
this.notifyStatus({
event: 'synthesized-audio',
vendor,
language,
servedFromCache,
'id': this.id
});
return {index, filePath: modifiedPath, playbackId};
}
return filePath;
} catch (err) {
this.logger.info({err}, 'Error synthesizing tts');
if (this.otelSpan) this.otelSpan.end();
@@ -296,8 +367,20 @@ class TtsTask extends Task {
}
};
const arr = this.text.map((t) => (this._validateURL(t) ? t : generateAudio(t)));
return (await Promise.all(arr)).filter((fp) => fp && fp.length);
// process all text segments in parallel will cause ordering issue
// so we attach index to each promise result and sort them later
const arr = this.text.map((t, index) => (this._validateURL(t) ?
Promise.resolve({index, filePath: t, playbackId: null}) : generateAudio(t, index)));
const results = await Promise.all(arr);
const sorted = results.sort((a, b) => a.index - b.index);
return sorted
.filter((fp) => fp.filePath && fp.filePath.length)
.map((r) => {
this.playbackIds.push(r.playbackId);
return r.filePath;
});
} catch (err) {
this.logger.info(err, 'TaskSay:exec error');
throw err;

View File

@@ -281,13 +281,17 @@ module.exports = (logger) => {
/* set stt options */
logger.info(`starting amd for vendor ${vendor} and language ${language}`);
const sttOpts = amd.setChannelVarsForStt({name: TaskName.Gather}, sttCredentials, language, {
vendor,
hints,
enhancedModel: true,
altLanguages: opts.recognizer?.altLanguages || [],
initialSpeechTimeoutMs: opts.resolveTimeoutMs,
});
/* if opts contains recognizer object use that config for stt, otherwise use defaults */
const rOpts = opts.recognizer ?
opts.recognizer :
{
vendor,
hints,
enhancedModel: true,
altLanguages: opts.recognizer?.altLanguages || [],
initialSpeechTimeoutMs: opts.resolveTimeoutMs,
};
const sttOpts = amd.setChannelVarsForStt({name: TaskName.Gather}, sttCredentials, language, rOpts);
await ep.set(sttOpts).catch((err) => logger.info(err, 'Error setting channel variables'));
@@ -418,7 +422,11 @@ module.exports = (logger) => {
}
if (ep.connected) {
ep.stopTranscription({vendor, bugname})
ep.stopTranscription({
vendor,
bugname,
gracefulShutdown: false
})
.catch((err) => logger.info(err, 'stopAmd: Error stopping transcription'));
task.emit('amd', {type: AmdEvents.Stopped});
ep.execute('avmd_stop').catch((err) => this.logger.info(err, 'Error stopping avmd'));

View File

@@ -65,7 +65,7 @@ class BackgroundTaskManager extends Emitter {
this.logger.info(`stopping background task: ${type}`);
task.removeAllListeners();
task.span.end();
task.kill();
task.kill(this.cs);
// Remove task from managed List
this.tasks.delete(type);
}

View File

@@ -79,7 +79,44 @@ class BaseRequestor extends Emitter {
return time.toFixed(0);
}
_parseHashParams(hash) {
// Remove the leading # if present
const hashString = hash.startsWith('#') ? hash.substring(1) : hash;
// Use URLSearchParams for parsing
const params = new URLSearchParams(hashString);
// Convert to a regular object
const result = {};
for (const [key, value] of params.entries()) {
result[key] = value;
}
return result;
}
/**
* Check if the error should be retried based on retry policy
* @param {Error} err - The error that occurred
* @param {string[]} rpValues - Array of retry policy values
* @returns {boolean} True if the error should be retried
*/
_shouldRetry(err, rpValues) {
// ct = connection timeout (ECONNREFUSED, ETIMEDOUT, etc)
const isCt = err.code === 'ECONNREFUSED' ||
err.code === 'ETIMEDOUT' ||
err.code === 'ECONNRESET' ||
err.code === 'ECONNABORTED';
// rt = request timeout
const isRt = err.name === 'TimeoutError';
// 4xx = client errors
const is4xx = err.statusCode >= 400 && err.statusCode < 500;
// 5xx = server errors
const is5xx = err.statusCode >= 500 && err.statusCode < 600;
// Check if error type is included in retry policy
return rpValues.includes('all') ||
(isCt && rpValues.includes('ct')) ||
(isRt && rpValues.includes('rt')) ||
(is4xx && rpValues.includes('4xx')) ||
(is5xx && rpValues.includes('5xx'));
}
}
module.exports = BaseRequestor;

View File

@@ -1,5 +1,6 @@
{
"TaskName": {
"Alert": "alert",
"Answer": "answer",
"Conference": "conference",
"Config": "config",
@@ -32,7 +33,7 @@
"Tag": "tag",
"Transcribe": "transcribe"
},
"AllowedSipRecVerbs": ["answer", "config", "gather", "transcribe", "listen", "tag"],
"AllowedSipRecVerbs": ["answer", "config", "gather", "transcribe", "listen", "tag", "hangup", "sip:decline"],
"AllowedConfirmSessionVerbs": ["config", "gather", "plays", "say", "tag"],
"CallStatus": {
"Trying": "trying",
@@ -93,7 +94,20 @@
"DeepgramTranscriptionEvents": {
"Transcription": "deepgram_transcribe::transcription",
"ConnectFailure": "deepgram_transcribe::connect_failed",
"Connect": "deepgram_transcribe::connect"
"Connect": "deepgram_transcribe::connect",
"Error": "deepgram_transcribe::error"
},
"DeepgramfluxTranscriptionEvents": {
"Transcription": "deepgramflux_transcribe::transcription",
"ConnectFailure": "deepgramflux_transcribe::connect_failed",
"Connect": "deepgramflux_transcribe::connect",
"Error": "deepgramflux_transcribe::error"
},
"GladiaTranscriptionEvents": {
"Transcription": "gladia_transcribe::transcription",
"ConnectFailure": "gladia_transcribe::connect_failed",
"Connect": "gladia_transcribe::connect",
"Error": "gladia_transcribe::error"
},
"SonioxTranscriptionEvents": {
"Transcription": "soniox_transcribe::transcription",
@@ -137,6 +151,18 @@
"Connect": "speechmatics_transcribe::connect",
"Error": "speechmatics_transcribe::error"
},
"OpenAITranscriptionEvents": {
"Transcription": "openai_transcribe::transcription",
"Translation": "openai_transcribe::translation",
"SpeechStarted": "openai_transcribe::speech_started",
"SpeechStopped": "openai_transcribe::speech_stopped",
"PartialTranscript": "openai_transcribe::partial_transcript",
"Info": "openai_transcribe::info",
"RecognitionStarted": "openai_transcribe::recognition_started",
"ConnectFailure": "openai_transcribe::connect_failed",
"Connect": "openai_transcribe::connect",
"Error": "openai_transcribe::error"
},
"JambonzTranscriptionEvents": {
"Transcription": "jambonz_transcribe::transcription",
"ConnectFailure": "jambonz_transcribe::connect_failed",
@@ -149,15 +175,30 @@
"ConnectFailure": "assemblyai_transcribe::connect_failed",
"Connect": "assemblyai_transcribe::connect"
},
"HoundifyTranscriptionEvents": {
"Transcription": "houndify_transcribe::transcription",
"Error": "houndify_transcribe::error",
"ConnectFailure": "houndify_transcribe::connect_failed",
"Connect": "houndify_transcribe::connect"
},
"VoxistTranscriptionEvents": {
"Transcription": "voxist_transcribe::transcription",
"Error": "voxist_transcribe::error",
"ConnectFailure": "voxist_transcribe::connect_failed",
"Connect": "voxist_transcribe::connect"
},
"CartesiaTranscriptionEvents": {
"Transcription": "cartesia_transcribe::transcription",
"Error": "cartesia_transcribe::error",
"ConnectFailure": "cartesia_transcribe::connect_failed",
"Connect": "cartesia_transcribe::connect"
},
"VadDetection": {
"Detection": "vad_detect:detection"
},
"SileroVadDetection": {
"Detection": "vad_silero:detect"
},
"ListenEvents": {
"Connect": "mod_audio_fork::connect",
"ConnectFailure": "mod_audio_fork::connect_failed",
@@ -182,6 +223,13 @@
"Disconnect": "openai_s2s::disconnect",
"ServerEvent": "openai_s2s::server_event"
},
"LlmEvents_Google": {
"Error": "error",
"Connect": "google_s2s::connect",
"ConnectFailure": "google_s2s::connect_failed",
"Disconnect": "google_s2s::disconnect",
"ServerEvent": "google_s2s::server_event"
},
"LlmEvents_Elevenlabs": {
"Error": "error",
"Connect": "elevenlabs_s2s::connect",
@@ -218,6 +266,7 @@
"KillReason": {
"Hangup": "hangup",
"Replaced": "replaced",
"ReferComplete": "refer-complete",
"MediaTimeout": "media_timeout"
},
"HookMsgTypes": [
@@ -286,7 +335,8 @@
"Empty": "tts_streaming::empty",
"Pause": "tts_streaming::pause",
"Resume": "tts_streaming::resume",
"ConnectFailure": "tts_streaming::connect_failed"
"ConnectFailure": "tts_streaming::connect_failed",
"Connected": "tts_streaming::connected"
},
"TtsStreamingConnectionStatus": {
"NotConnected": "not_connected",
@@ -306,5 +356,8 @@
"WS_CLOSE_CODES": {
"NormalClosure": 1000,
"GoingAway": 1001
}
},
"NON_FANTAL_ERRORS": [
"File Not Found"
]
}

View File

@@ -76,10 +76,20 @@ const speechMapper = (cred) => {
else if ('deepgram' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
obj.model_id = o.model_id;
obj.deepgram_stt_uri = o.deepgram_stt_uri;
obj.deepgram_tts_uri = o.deepgram_tts_uri;
obj.deepgram_stt_use_tls = o.deepgram_stt_use_tls;
}
else if ('gladia' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
obj.region = o.region;
}
else if ('deepgramflux' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
}
else if ('soniox' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
@@ -96,6 +106,7 @@ const speechMapper = (cred) => {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
obj.model_id = o.model_id;
obj.api_uri = o.api_uri;
obj.options = o.options;
}
else if ('playht' === obj.vendor) {
@@ -109,6 +120,7 @@ const speechMapper = (cred) => {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
obj.model_id = o.model_id;
obj.stt_model_id = o.stt_model_id;
obj.embedding = o.embedding;
obj.options = o.options;
}
@@ -118,9 +130,28 @@ const speechMapper = (cred) => {
obj.model_id = o.model_id;
obj.options = o.options;
}
else if ('resemble' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
obj.resemble_tts_use_tls = o.resemble_tts_use_tls;
obj.resemble_tts_uri = o.resemble_tts_uri;
}
else if ('inworld' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
obj.model_id = o.model_id;
obj.options = o.options;
}
else if ('assemblyai' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
obj.service_version = o.service_version;
}
else if ('houndify' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.client_id = o.client_id;
obj.client_key = o.client_key;
obj.user_id = o.user_id;
}
else if ('voxist' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
@@ -142,6 +173,11 @@ const speechMapper = (cred) => {
obj.api_key = o.api_key;
obj.speechmatics_stt_uri = o.speechmatics_stt_uri;
}
else if ('openai' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
obj.model_id = o.model_id;
}
else if (obj.vendor.startsWith('custom:')) {
const o = JSON.parse(decrypt(credential));
obj.auth_token = o.auth_token;

View File

@@ -41,8 +41,9 @@ class HttpRequestor extends BaseRequestor {
constructor(logger, account_sid, hook, secret) {
super(logger, account_sid, hook, secret);
this.method = hook.method || 'POST';
this.method = hook.method?.toUpperCase() || 'POST';
this.authHeader = basicAuth(hook.username, hook.password);
this.backoffMs = 500;
assert(this._isAbsoluteUrl(this.url));
assert(['GET', 'POST'].includes(this.method));
@@ -102,15 +103,15 @@ class HttpRequestor extends BaseRequestor {
* @param {string} [hook.password] - if basic auth is protecting the endpoint
* @param {object} [params] - request parameters
*/
async request(type, hook, params, httpHeaders = {}) {
async request(type, hook, params, httpHeaders = {}, span) {
/* jambonz:error only sent over ws */
if (type === 'jambonz:error') return;
assert(HookMsgTypes.includes(type));
const payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null;
const payload = params ? snakeCaseKeys(params, ['customerData', 'sip', 'env_vars', 'args']) : null;
const url = hook.url || hook;
const method = hook.method || 'POST';
const method = hook.method?.toUpperCase() || 'POST';
let buf = '';
httpHeaders = {
...httpHeaders,
@@ -118,7 +119,7 @@ class HttpRequestor extends BaseRequestor {
};
assert.ok(url, 'HttpRequestor:request url was not provided');
assert.ok, (['GET', 'POST'].includes(method), `HttpRequestor:request method must be 'GET' or 'POST' not ${method}`);
assert.ok(['GET', 'POST'].includes(method), `HttpRequestor:request method must be 'GET' or 'POST' not ${method}`);
const startAt = process.hrtime();
/* if we have an absolute url, and it is ws then do a websocket connection */
@@ -131,30 +132,51 @@ class HttpRequestor extends BaseRequestor {
this.close();
this.emit('handover', requestor);
}
return requestor.request('session:new', hook, params, httpHeaders);
return requestor.request('session:new', hook, params, httpHeaders, span);
}
let newClient;
try {
this.backoffMs = 500;
// Parse URL and extract hash parameters for retry configuration
// Prepare request options - only do this once
const absUrl = this._isRelativeUrl(url) ? `${this.baseUrl}${url}` : url;
const parsedUrl = parseUrl(absUrl);
const hash = parsedUrl.hash || '';
const hashObj = hash ? this._parseHashParams(hash) : {};
// Retry policy: rp valid values: 4xx, 5xx, ct, rt, all, default is ct
// Retry count: rc valid values: 1-5, default is 0
// rc is the number of attempts we'll make AFTER the initial try
const rc = hash ? Math.min(Math.abs(parseInt(hashObj.rc || '0')), 5) : 0;
const rp = hashObj.rp || 'ct';
const rpValues = rp.split(',').map((v) => v.trim());
let retryCount = 0;
// Set up client, path and query parameters - only do this once
let client, path, query;
if (this._isRelativeUrl(url)) {
client = this.client;
path = url;
}
else {
const u = parseUrl(url);
if (u.resource === this._resource && u.port === this._port && u.protocol === this._protocol) {
if (parsedUrl.resource === this._resource &&
parsedUrl.port === this._port &&
parsedUrl.protocol === this._protocol) {
client = this.client;
path = u.pathname;
query = u.query;
path = parsedUrl.pathname;
query = parsedUrl.query;
}
else {
if (u.port) client = newClient = new Client(`${u.protocol}://${u.resource}:${u.port}`);
else client = newClient = new Client(`${u.protocol}://${u.resource}`);
path = u.pathname;
query = u.query;
if (parsedUrl.port) {
client = newClient = new Client(`${parsedUrl.protocol}://${parsedUrl.resource}:${parsedUrl.port}`);
}
else client = newClient = new Client(`${parsedUrl.protocol}://${parsedUrl.resource}`);
path = parsedUrl.pathname;
query = parsedUrl.query;
}
}
const sigHeader = this._generateSigHeader(payload, this.secret);
const hdrs = {
...sigHeader,
@@ -162,20 +184,8 @@ class HttpRequestor extends BaseRequestor {
...httpHeaders,
...('POST' === method && {'Content-Type': 'application/json'})
};
const absUrl = this._isRelativeUrl(url) ? `${this.baseUrl}${url}` : url;
this.logger.debug({url, absUrl, hdrs}, 'send webhook');
const {statusCode, headers, body} = HTTP_PROXY_IP ? await request(
this.baseUrl,
{
path,
query,
method,
headers: hdrs,
...('POST' === method && {body: JSON.stringify(payload)}),
timeout: HTTP_TIMEOUT,
followRedirects: false
}
) : await client.request({
const requestOptions = {
path,
query,
method,
@@ -183,14 +193,51 @@ class HttpRequestor extends BaseRequestor {
...('POST' === method && {body: JSON.stringify(payload)}),
timeout: HTTP_TIMEOUT,
followRedirects: false
});
if (![200, 202, 204].includes(statusCode)) {
const err = new HTTPResponseError(statusCode);
throw err;
}
if (headers['content-type']?.includes('application/json')) {
buf = await body.json();
};
// Simplified makeRequest function that just executes the HTTP request
const makeRequest = async() => {
this.logger.debug({url, absUrl, hdrs, retryCount},
`send webhook${retryCount > 0 ? ' (retry ' + retryCount + ')' : ''}`);
const {statusCode, headers, body} = HTTP_PROXY_IP ? await request(
this.baseUrl,
requestOptions
) : await client.request(requestOptions);
if (![200, 202, 204].includes(statusCode)) {
const err = new HTTPResponseError(statusCode);
throw err;
}
if (headers['content-type']?.includes('application/json')) {
return await body.json();
}
return '';
};
while (true) {
try {
buf = await makeRequest();
break; // Success, exit the retry loop
} catch (err) {
retryCount++;
// Check if we should retry
if (retryCount <= rc && this._shouldRetry(err, rpValues)) {
this.logger.info(
{err, baseUrl: this.baseUrl, url, retryCount, maxRetries: rc},
`Retrying request (${retryCount}/${rc})`
);
const delay = this.backoffMs;
this.backoffMs = this.backoffMs < 2000 ? this.backoffMs * 2 : (this.backoffMs + 2000);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw err;
}
}
if (newClient) newClient.close();
} catch (err) {
if (err.statusCode) {
@@ -219,10 +266,10 @@ class HttpRequestor extends BaseRequestor {
const rtt = this._roundTrip(startAt);
if (buf) this.stats.histogram('app.hook.response_time', rtt, ['hook_type:app']);
if (buf && Array.isArray(buf)) {
if (buf && (Array.isArray(buf) || type == 'llm:tool-call')) {
this.logger.info({response: buf}, `HttpRequestor:request ${method} ${url} succeeded in ${rtt}ms`);
return buf;
}
return buf;
}
}

View File

@@ -31,18 +31,26 @@ function getLocalIp() {
return '127.0.0.1'; // Fallback to localhost if no suitable interface found
}
function initMS(logger, wrapper, ms) {
function initMS(logger, wrapper, ms, {
onFreeswitchConnect,
onFreeswitchDisconnect
}) {
Object.assign(wrapper, {ms, active: true, connects: 1});
logger.info(`connected to freeswitch at ${ms.address}`);
onFreeswitchConnect(wrapper);
ms.conn
.on('esl::end', () => {
wrapper.active = false;
wrapper.connects = 0;
logger.info(`lost connection to freeswitch at ${ms.address}`);
onFreeswitchDisconnect(wrapper);
ms.removeAllListeners();
})
.on('esl::ready', () => {
if (wrapper.connects > 0) {
logger.info(`connected to freeswitch at ${ms.address}`);
logger.info(`esl::ready connected to freeswitch at ${ms.address}`);
}
wrapper.connects = 1;
wrapper.active = true;
@@ -56,7 +64,10 @@ function initMS(logger, wrapper, ms) {
});
}
function installSrfLocals(srf, logger) {
function installSrfLocals(srf, logger, {
onFreeswitchConnect = () => {},
onFreeswitchDisconnect = () => {}
}) {
logger.debug('installing srf locals');
assert(!srf.locals.dbHelpers);
const {tracer} = srf.locals.otel;
@@ -91,7 +102,10 @@ function installSrfLocals(srf, logger) {
mediaservers.push(val);
try {
const ms = await mrf.connect(fs);
initMS(logger, val, ms);
initMS(logger, val, ms, {
onFreeswitchConnect,
onFreeswitchDisconnect
});
}
catch (err) {
logger.info({err}, `failed connecting to freeswitch at ${fs.address}, will retry shortly: ${err.message}`);
@@ -102,9 +116,15 @@ function installSrfLocals(srf, logger) {
for (const val of mediaservers) {
if (val.connects === 0) {
try {
// make sure all listeners are removed before reconnecting
val.ms?.disconnect();
val.ms = null;
logger.info({mediaserver: val.opts}, 'Retrying initial connection to media server');
const ms = await mrf.connect(val.opts);
initMS(logger, val, ms);
initMS(logger, val, ms, {
onFreeswitchConnect,
onFreeswitchDisconnect
});
} catch (err) {
logger.info({err}, `failed connecting to freeswitch at ${val.opts.address}, will retry shortly`);
}
@@ -153,7 +173,8 @@ function installSrfLocals(srf, logger) {
lookupAccountCapacitiesBySid,
lookupSmppGateways,
lookupClientByAccountAndUsername,
lookupSystemInformation
lookupSystemInformation,
lookupLcrByAccount
} = require('@jambonz/db-helpers')({
host: JAMBONES_MYSQL_HOST,
user: JAMBONES_MYSQL_USER,
@@ -259,7 +280,8 @@ function installSrfLocals(srf, logger) {
retrieveByPatternSortedSet,
sortedSetLength,
sortedSetPositionByPattern,
getVerbioAccessToken
getVerbioAccessToken,
lookupLcrByAccount
},
parentLogger: logger,
getSBC,

103
lib/utils/llm-mcp.js Normal file
View File

@@ -0,0 +1,103 @@
const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
class LlmMcpService {
constructor(logger, mcpServers) {
this.logger = logger;
this.mcpServers = mcpServers || [];
this.mcpClients = [];
}
// make sure we call init() before using any of the mcp clients
// this is to ensure that we have a valid connection to the MCP server
// and that we have collected the available tools.
async init() {
if (this.mcpClients.length > 0) {
return;
}
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
for (const server of this.mcpServers) {
const { url } = server;
if (url) {
try {
const transport = new SSEClientTransport(new URL(url), {});
const client = new Client({ name: 'Jambonz MCP Client', version: '1.0.0' });
await client.connect(transport);
// collect available tools
const { tools } = await client.listTools();
this.mcpClients.push({
url,
client,
tools
});
} catch (err) {
this.logger.error(`LlmMcpService: Failed to connect to MCP server at ${url}: ${err.message}`);
}
}
}
}
async getAvailableMcpTools() {
// returns a list of available tools from all MCP clients
const tools = [];
for (const mcpClient of this.mcpClients) {
const {tools: availableTools} = mcpClient;
if (availableTools) {
tools.push(...availableTools);
}
}
return tools;
}
async getMcpClientByToolName(name) {
for (const mcpClient of this.mcpClients) {
const { tools } = mcpClient;
if (tools && tools.some((tool) => tool.name === name)) {
return mcpClient.client;
}
}
return null;
}
async getMcpClientByToolId(id) {
for (const mcpClient of this.mcpClients) {
const { tools } = mcpClient;
if (tools && tools.some((tool) => tool.id === id)) {
return mcpClient.client;
}
}
return null;
}
async callMcpTool(name, input) {
const client = await this.getMcpClientByToolName(name);
if (client) {
try {
const result = await client.callTool({
name,
arguments: input,
});
this.logger.debug({result}, 'LlmMcpService - result');
return result;
} catch (err) {
this.logger.error({err}, 'LlmMcpService - error calling tool');
throw err;
}
}
}
async close() {
for (const mcpClient of this.mcpClients) {
const { client } = mcpClient;
if (client) {
await client.close();
this.logger.debug({url: mcpClient.url}, 'LlmMcpService - mcp client closed');
}
}
this.mcpClients = [];
}
}
module.exports = LlmMcpService;

115
lib/utils/media-endpoint.js Normal file
View File

@@ -0,0 +1,115 @@
const {
JAMBONES_USE_FREESWITCH_TIMER_FD,
JAMBONES_MEDIA_TIMEOUT_MS,
JAMBONES_MEDIA_HOLD_TIMEOUT_MS,
JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS,
} = require('../config');
const { sleepFor } = require('./helpers');
const createMediaEndpoint = async(srf, logger, {
activeMs,
drachtioFsmrfOptions = {},
onHoldMusic,
inbandDtmfEnabled,
mediaTimeoutHandler,
} = {}) => {
const { getFreeswitch } = srf.locals;
const ms = activeMs || getFreeswitch();
if (!ms)
throw new Error('no available Freeswitch for creating media endpoint');
const ep = await ms.createEndpoint(drachtioFsmrfOptions);
// Configure the endpoint
const opts = {
...(onHoldMusic && {holdMusic: `shout://${onHoldMusic.replace(/^https?:\/\//, '')}`}),
...(JAMBONES_USE_FREESWITCH_TIMER_FD && {timer_name: 'timerfd'}),
...(JAMBONES_MEDIA_TIMEOUT_MS && {media_timeout: JAMBONES_MEDIA_TIMEOUT_MS}),
...(JAMBONES_MEDIA_HOLD_TIMEOUT_MS && {media_hold_timeout: JAMBONES_MEDIA_HOLD_TIMEOUT_MS})
};
if (Object.keys(opts).length > 0) {
ep.set(opts);
}
// inbandDtmfEnabled
if (inbandDtmfEnabled) {
// https://developer.signalwire.com/freeswitch/FreeSWITCH-Explained/Modules/mod-dptools/6587132/#0-about
ep.execute('start_dtmf').catch((err) => {
logger.error('Error starting inband DTMF', { error: err });
});
ep.inbandDtmfEnabled = true;
}
// Handle Media Timeout
if (mediaTimeoutHandler) {
ep.once('destroy', (evt) => {
mediaTimeoutHandler(evt, ep);
});
}
// Handle graceful shutdown for endpoint if required
if (JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS > 0) {
const getEpGracefulShutdownPromise = () => {
if (!ep.gracefulShutdownPromise) {
ep.gracefulShutdownPromise = new Promise((resolve) => {
// this resolver will be called when stt task received transcription.
ep.gracefulShutdownResolver = () => {
resolve();
ep.gracefulShutdownPromise = null;
};
});
}
return ep.gracefulShutdownPromise;
};
const gracefulShutdownHandler = async() => {
// resolve when one of the following happens:
// 1. stt task received transcription
// 2. JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS passed
await Promise.race([
getEpGracefulShutdownPromise(),
sleepFor(JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS)
]);
};
const origStartTranscription = ep.startTranscription.bind(ep);
ep.startTranscription = async(...args) => {
try {
const result = await origStartTranscription(...args);
ep.isTranscribeActive = true;
return result;
} catch (err) {
ep.isTranscribeActive = false;
throw err;
}
};
const origStopTranscription = ep.stopTranscription.bind(ep);
ep.stopTranscription = async(opts = {}, ...args) => {
const { gracefulShutdown = true, ...others } = opts;
if (ep.isTranscribeActive && gracefulShutdown) {
// only wait for graceful shutdown if transcription is active
await gracefulShutdownHandler();
}
try {
const result = await origStopTranscription({...others}, ...args);
ep.isTranscribeActive = false;
return result;
} catch (err) {
ep.isTranscribeActive = false;
throw err;
}
};
const origDestroy = ep.destroy.bind(ep);
ep.destroy = async() => {
if (ep.isTranscribeActive) {
await gracefulShutdownHandler();
}
return await origDestroy();
};
}
return ep;
};
module.exports = {
createMediaEndpoint,
};

View File

@@ -12,21 +12,15 @@ const deepcopy = require('deepcopy');
const moment = require('moment');
const stripCodecs = require('./strip-ancillary-codecs');
const RootSpan = require('./call-tracer');
const uuidv4 = require('uuid-random');
const crypto = require('crypto');
const HttpRequestor = require('./http-requestor');
const WsRequestor = require('./ws-requestor');
const {makeOpusFirst} = require('./sdp-utils');
const {
JAMBONES_USE_FREESWITCH_TIMER_FD,
JAMBONES_MEDIA_TIMEOUT_MS,
JAMBONES_MEDIA_HOLD_TIMEOUT_MS,
JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS
} = require('../config');
const { sleepFor } = require('./helpers');
const {makeOpusFirst, removeVideoSdp} = require('./sdp-utils');
const { createMediaEndpoint } = require('./media-endpoint');
class SingleDialer extends Emitter {
constructor({logger, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan, dialTask,
onHoldMusic}) {
onHoldMusic, tmpFiles}) {
super();
assert(target.type);
@@ -47,9 +41,10 @@ class SingleDialer extends Emitter {
this.callGone = false;
this.callSid = uuidv4();
this.callSid = crypto.randomUUID();
this.dialTask = dialTask;
this.onHoldMusic = onHoldMusic;
this.tmpFiles = tmpFiles;
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
}
@@ -98,6 +93,7 @@ class SingleDialer extends Emitter {
};
}
this.ms = ms;
this.srf = srf;
let uri, to, inviteSpan;
try {
switch (this.target.type) {
@@ -139,8 +135,7 @@ class SingleDialer extends Emitter {
this.updateCallStatus = srf.locals.dbHelpers.updateCallStatus;
this.serviceUrl = srf.locals.serviceUrl;
this.ep = await ms.createEndpoint();
this._configMsEndpoint();
this.ep = await this._createMediaEndpoint();
this.logger.debug(`SingleDialer:exec - created endpoint ${this.ep.uuid}`);
/**
@@ -154,15 +149,21 @@ class SingleDialer extends Emitter {
return;
}
let lastSdp;
const connectStream = async(remoteSdp) => {
const connectStream = async(remoteSdp, isVideoCall) => {
if (remoteSdp === lastSdp) return;
if (process.env.JAMBONES_VIDEO_CALLS_ENABLED_IN_FS && !isVideoCall) {
remoteSdp = removeVideoSdp(remoteSdp);
}
lastSdp = remoteSdp;
return this.ep.modify(remoteSdp);
};
let localSdp = this.ep.local.sdp;
if (process.env.JAMBONES_VIDEO_CALLS_ENABLED_IN_FS && !opts.isVideoCall) {
localSdp = removeVideoSdp(localSdp);
}
Object.assign(opts, {
proxy: `sip:${this.sbcAddress}`,
localSdp: opts.opusFirst ? makeOpusFirst(this.ep.local.sdp) : this.ep.local.sdp
localSdp: opts.opusFirst ? makeOpusFirst(localSdp) : localSdp
});
if (this.target.auth) opts.auth = this.target.auth;
inviteSpan = this.startSpan('invite', {
@@ -224,13 +225,13 @@ class SingleDialer extends Emitter {
status.callStatus = CallStatus.EarlyMedia;
this.emit('earlyMedia');
}
connectStream(prov.body);
connectStream(prov.body, opts.isVideoCall);
}
else status.callStatus = CallStatus.Ringing;
this.emit('callStatusChange', status);
}
});
await connectStream(this.dlg.remote.sdp);
await connectStream(this.dlg.remote.sdp, opts.isVideoCall);
this.dlg.callSid = this.callSid;
this.inviteInProgress = null;
this.emit('callStatusChange', {
@@ -273,7 +274,12 @@ class SingleDialer extends Emitter {
this.logger.info('dial is onhold, emit event');
this.emit('reinvite', req, res);
} else {
const newSdp = await this.ep.modify(req.body);
let newSdp = await this.ep.modify(req.body);
// in case of reINVITE if video call is enabled in FS and the call is not a video call,
// remove video media from the SDP
if (process.env.JAMBONES_VIDEO_CALLS_ENABLED_IN_FS && !this.opts?.isVideoCall) {
newSdp = removeVideoSdp(newSdp);
}
res.send(200, {body: newSdp});
this.logger.info({offer: req.body, answer: newSdp}, 'SingleDialer:exec: handling reINVITE');
}
@@ -323,7 +329,13 @@ class SingleDialer extends Emitter {
*/
async kill(Reason) {
this.killed = true;
if (this.inviteInProgress) await this.inviteInProgress.cancel();
if (this.inviteInProgress) {
try {
await this.inviteInProgress.cancel();
} catch (err) {
this.logger.error({err}, 'SingleDialer:kill error cancelling invite');
}
}
else if (this.dlg && this.dlg.connected) {
const duration = moment().diff(this.dlg.connectTime, 'seconds');
this.logger.debug('SingleDialer:kill hanging up called party');
@@ -341,43 +353,19 @@ class SingleDialer extends Emitter {
}
}
_configMsEndpoint() {
const opts = {
...(this.onHoldMusic && {holdMusic: `shout://${this.onHoldMusic.replace(/^https?:\/\//, '')}`}),
...(JAMBONES_USE_FREESWITCH_TIMER_FD && {timer_name: 'timerfd'}),
...(JAMBONES_MEDIA_TIMEOUT_MS && {media_timeout: JAMBONES_MEDIA_TIMEOUT_MS}),
...(JAMBONES_MEDIA_HOLD_TIMEOUT_MS && {media_hold_timeout: JAMBONES_MEDIA_HOLD_TIMEOUT_MS})
};
if (Object.keys(opts).length > 0) {
this.ep.set(opts);
}
if (this.dialTask?.inbandDtmfEnabled && !this.ep.inbandDtmfEnabled) {
// https://developer.signalwire.com/freeswitch/FreeSWITCH-Explained/Modules/mod-dptools/6587132/#0-about
try {
this.ep.execute('start_dtmf');
this.ep.inbandDtmfEnabled = true;
} catch (err) {
this.logger.info(err, 'place-outdial:_configMsEndpoint - error enable inband DTMF');
}
}
async _handleMediaTimeout(evt, ep) {
this.logger.info({evt}, 'SingleDialer:_handleMediaTimeout - media timeout event received');
this.dialTask.kill(this.dialTask.cs, 'media-timeout');
}
const origDestroy = this.ep.destroy.bind(this.ep);
this.ep.destroy = async() => {
try {
if (this.dialTask.transcribeTask && JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS) {
// transcribe task is being used, wait for some time before destroy
// if final transcription is received but endpoint is already closed,
// freeswitch module will not be able to send the transcription
this.logger.info('SingleDialer:_configMsEndpoint -' +
' Dial with transcribe task, wait for some time before destroy');
await sleepFor(JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS);
}
await origDestroy();
} catch (err) {
this.logger.error(err, 'SingleDialer:_configMsEndpoint - error destroying endpoint');
}
};
async _createMediaEndpoint(drachtioFsmrfOptions = {}) {
return await createMediaEndpoint(this.srf, this.logger, {
acactiveMs: this.ms,
drachtioFsmrfOptions,
onHoldMusic: this.onHoldMusic,
inbandDtmfEnabled: this.dialTask?.inbandDtmfEnabled,
mediaTimeoutHandler: this._handleMediaTimeout.bind(this),
});
}
/**
@@ -420,7 +408,8 @@ class SingleDialer extends Emitter {
accountInfo: this.accountInfo,
tasks,
rootSpan: this.rootSpan,
req: this.req
req: this.req,
tmpFiles: this.tmpFiles,
});
await cs.exec();
@@ -517,8 +506,7 @@ class SingleDialer extends Emitter {
assert(this.dlg && this.dlg.connected && !this.ep);
this.logger.debug('SingleDialer:reAnchorMedia: re-anchoring media after partial media');
this.ep = await this.ms.createEndpoint({remoteSdp: this.dlg.remote.sdp});
this._configMsEndpoint();
this.ep = await this._createMediaEndpoint({remoteSdp: this.dlg.remote.sdp});
await this.dlg.modify(this.ep.local.sdp, {
headers: {
'X-Reason': 'anchor-media'
@@ -555,11 +543,12 @@ class SingleDialer extends Emitter {
function placeOutdial({
logger, srf, ms, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan, dialTask,
onHoldMusic
onHoldMusic, tmpFiles
}) {
const myOpts = deepcopy(opts);
const sd = new SingleDialer({
logger, sbcAddress, target, myOpts, application, callInfo, accountInfo, rootSpan, startSpan, dialTask, onHoldMusic
logger, sbcAddress, target, opts: myOpts, application, callInfo,
accountInfo, rootSpan, startSpan, dialTask, onHoldMusic, tmpFiles
});
sd.exec(srf, ms, myOpts);
return sd;

View File

@@ -0,0 +1,91 @@
// lib/utils/process-monitor.js
const fs = require('fs');
const path = require('path');
class ProcessMonitor {
constructor(logger) {
this.logger = logger;
this.packageInfo = this.getPackageInfo();
this.processName = this.packageInfo.name || 'unknown-app';
}
getPackageInfo() {
try {
const packagePath = path.join(process.cwd(), 'package.json');
return JSON.parse(fs.readFileSync(packagePath, 'utf8'));
} catch (e) {
return { name: 'unknown', version: 'unknown' };
}
}
logStartup(additionalInfo = {}) {
const startupInfo = {
msg: `${this.processName} started`,
app_name: this.processName,
app_version: this.packageInfo.version,
pid: process.pid,
ppid: process.ppid,
pm2_instance_id: process.env.NODE_APP_INSTANCE || 'not_pm2',
pm2_id: process.env.pm_id,
is_pm2: !!process.env.PM2,
node_version: process.version,
uptime: process.uptime(),
timestamp: new Date().toISOString(),
...additionalInfo
};
this.logger.info(startupInfo);
return startupInfo;
}
setupSignalHandlers() {
// Log when we receive signals that would cause restart
process.on('SIGINT', () => {
this.logger.info({
msg: 'SIGINT received',
app_name: this.processName,
pid: process.pid,
ppid: process.ppid,
uptime: process.uptime(),
timestamp: new Date().toISOString()
});
process.exit(0);
});
process.on('SIGTERM', () => {
this.logger.info({
msg: 'SIGTERM received',
app_name: this.processName,
pid: process.pid,
ppid: process.ppid,
uptime: process.uptime(),
timestamp: new Date().toISOString()
});
process.exit(0);
});
process.on('uncaughtException', (error) => {
this.logger.error({
msg: 'Uncaught exception - process will restart',
app_name: this.processName,
error: error.message,
stack: error.stack,
pid: process.pid,
timestamp: new Date().toISOString()
});
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
this.logger.error({
msg: 'Unhandled rejection',
app_name: this.processName,
reason,
pid: process.pid,
timestamp: new Date().toISOString()
});
});
}
}
module.exports = ProcessMonitor;

View File

@@ -1,5 +1,5 @@
const assert = require('assert');
const uuidv4 = require('uuid-random');
const crypto = require('crypto');
const {LifeCycleEvents, FS_UUID_SET_NAME} = require('./constants');
const Emitter = require('events');
const debug = require('debug')('jambonz:feature-server');
@@ -130,7 +130,7 @@ module.exports = (logger) => {
logger.info('disabling OPTIONS pings since we are running as a kubernetes service');
const {srf} = require('../..');
const {addToSet} = srf.locals.dbHelpers;
const uuid = srf.locals.fsUUID = uuidv4();
const uuid = srf.locals.fsUUID = crypto.randomUUID();
/* in case redis is restarted, re-insert our key every so often */
setInterval(() => {

View File

@@ -35,6 +35,12 @@ const makeOpusFirst = (sdp) => {
}
return sdpTransform.write(parsedSdp);
};
const removeVideoSdp = (sdp) => {
const parsedSdp = sdpTransform.parse(sdp);
// Filter out video media sections, keeping only non-video media
parsedSdp.media = parsedSdp.media.filter((media) => media.type !== 'video');
return sdpTransform.write(parsedSdp);
};
const extractSdpMedia = (sdp) => {
const parsedSdp1 = sdpTransform.parse(sdp);
@@ -54,5 +60,6 @@ module.exports = {
mergeSdpMedia,
extractSdpMedia,
isOpusFirst,
makeOpusFirst
makeOpusFirst,
removeVideoSdp
};

View File

@@ -1,5 +1,5 @@
const xmlParser = require('xml2js').parseString;
const uuidv4 = require('uuid-random');
const crypto = require('crypto');
const parseUri = require('drachtio-srf').parseUri;
const transform = require('sdp-transform');
const debug = require('debug')('jambonz:feature-server');
@@ -52,7 +52,7 @@ const parseSiprecPayload = (req, logger) => {
const arr = /^([^]+)(m=[^]+?)(m=[^]+?)$/.exec(sdp);
opts.sdp1 = `${arr[1]}${arr[2]}`;
opts.sdp2 = `${arr[1]}${arr[3]}\r\n`;
opts.sessionId = uuidv4();
opts.sessionId = crypto.randomUUID();
logger.info({ payload: req.payload }, 'SIPREC payload with no metadata (e.g. Cisco NBR)');
resolve(opts);
} else if (!sdp || !meta) {
@@ -64,7 +64,7 @@ const parseSiprecPayload = (req, logger) => {
if (err) { throw err; }
opts.recordingData = result ;
opts.sessionId = uuidv4() ;
opts.sessionId = crypto.randomUUID();
const arr = /^([^]+)(m=[^]+?)(m=[^]+?)$/.exec(sdp) ;
opts.sdp1 = `${arr[1]}${arr[2]}` ;

View File

@@ -0,0 +1,74 @@
const EventEmitter = require('events');
/**
* A specialized EventEmitter that caches the most recent event emissions.
* When new listeners are added, they immediately receive the most recent
* event if it was previously emitted. This is useful for handling state
* changes where late subscribers need to know the current state.
*
* Features:
* - Caches the most recent emission for each event type
* - New listeners immediately receive the cached event if available
* - Supports both regular (on) and one-time (once) listeners
* - Maintains compatibility with Node's EventEmitter interface
*/
class StickyEventEmitter extends EventEmitter {
constructor() {
super();
this._eventCache = new Map();
this._onceListeners = new Map(); // For storing once listeners if needed
}
destroy() {
this._eventCache.clear();
this._onceListeners.clear();
this.removeAllListeners();
}
emit(event, ...args) {
// Store the event and its args
this._eventCache.set(event, args);
// If there are any 'once' listeners waiting, call them
if (this._onceListeners.has(event)) {
const listeners = this._onceListeners.get(event);
for (const listener of listeners) {
listener(...args);
}
if (this.onSuccess) {
this.onSuccess();
}
this._onceListeners.delete(event);
// return from here as the event listener is already called
// this is to avoid calling the native emit method which
// will call the event listener again
return true;
}
return super.emit(event, ...args);
}
on(event, listener) {
if (this._eventCache.has(event)) {
listener(...this._eventCache.get(event));
}
return super.on(event, listener);
}
once(event, listener) {
if (this._eventCache.has(event)) {
listener(...this._eventCache.get(event));
if (this.onSuccess) {
this.onSuccess();
}
} else {
// Store listener in case emit comes before
if (!this._onceListeners.has(event)) {
this._onceListeners.set(event, []);
}
this._onceListeners.get(event).push(listener);
super.once(event, listener); // Also attach to native once
}
return this;
}
}
module.exports = StickyEventEmitter;

View File

@@ -0,0 +1,197 @@
const { assert } = require('console');
const Emitter = require('events');
const {
VadDetection,
SileroVadDetection
} = require('../utils/constants.json');
class SttLatencyCalculator extends Emitter {
constructor({ logger, cs}) {
super();
this.logger = logger;
this.cs = cs;
this.isRunning = false;
this.isInTalkSpurt = false;
this.start_talking_time = 0;
this.talkspurts = [];
this.vendor = this.cs.vad?.vendor || 'silero';
this.stt_start_time = 0;
this.stt_stop_time = 0;
this.stt_on_transcription_time = 0;
}
set sttStartTime(time) {
this.stt_start_time = time;
}
get sttStartTime() {
return this.stt_start_time || 0;
}
set sttStopTime(time) {
this.stt_stop_time = time;
}
get sttStopTime() {
return this.stt_stop_time || 0;
}
set sttOnTranscriptionTime(time) {
this.stt_on_transcription_time = time;
}
get sttOnTranscriptionTime() {
return this.stt_on_transcription_time || 0;
}
_onVadDetected(_ep, _evt, fsEvent) {
if (fsEvent.getHeader('detected-event') === 'stop_talking') {
if (this.isInTalkSpurt) {
this.talkspurts.push({
start: this.start_talking_time,
stop: Date.now()
});
}
this.start_talking_time = 0;
this.isInTalkSpurt = false;
} else if (fsEvent.getHeader('detected-event') === 'start_talking') {
this.start_talking_time = Date.now();
this.isInTalkSpurt = true;
}
}
_startVad() {
assert(!this.isRunning, 'Latency calculator is already running');
assert(this.cs.ep, 'Callsession has no endpoint to start the latency calculator');
const ep = this.cs.ep;
if (!ep.sttLatencyVadHandler) {
ep.sttLatencyVadHandler = this._onVadDetected.bind(this, ep);
if (this.vendor === 'silero') {
ep.addCustomEventListener(SileroVadDetection.Detection, ep.sttLatencyVadHandler);
} else {
ep.addCustomEventListener(VadDetection.Detection, ep.sttLatencyVadHandler);
}
}
this.stop_talking_time = 0;
this.start_talking_time = 0;
this.vad = {
...(this.cs.vad || {}),
strategy: 'continuous',
bugname: 'stt-latency-calculator-vad',
vendor: this.vendor
};
ep.startVadDetection(this.vad);
this.isRunning = true;
}
_stopVad() {
if (this.isRunning) {
this.logger.warn('Latency calculator is still running, stopping VAD detection');
const ep = this.cs.ep;
ep.stopVadDetection(this.vad);
if (ep.sttLatencyVadHandler) {
if (this.vendor === 'silero') {
this.ep?.removeCustomEventListener(SileroVadDetection.Detection, ep.sttLatencyVadHandler);
} else {
this.ep?.removeCustomEventListener(VadDetection.Detection, ep.sttLatencyVadHandler);
}
ep.sttLatencyVadHandler = null;
}
this.isRunning = false;
this.logger.info('STT Latency Calculator stopped');
}
}
start() {
if (this.isRunning) {
this.logger.warn('Latency calculator is already running');
return;
}
if (!this.cs.ep) {
this.logger.error('Callsession has no endpoint to start the latency calculator');
return;
}
this._startVad();
this.logger.debug('STT Latency Calculator started');
}
stop() {
this._stopVad();
}
toUnixTimestamp(date) {
return Math.floor(date / 1000);
}
calculateLatency() {
if (!this.isRunning) {
this.logger.debug('Latency calculator is not running, cannot calculate latency, returning default values');
return null;
}
const stt_stop_time = this.stt_stop_time || Date.now();
if (this.isInTalkSpurt) {
this.talkspurts.push({
start: this.start_talking_time,
stop: stt_stop_time
});
this.isInTalkSpurt = false;
this.start_talking_time = 0;
}
const stt_on_transcription_time = this.stt_on_transcription_time || stt_stop_time;
const start_talking_time = this.talkspurts[0]?.start;
let lastIdx = this.talkspurts.length - 1;
lastIdx = lastIdx < 0 ? 0 : lastIdx;
const stop_talking_time = this.talkspurts[lastIdx]?.stop || stt_stop_time;
return {
stt_start_time: this.toUnixTimestamp(this.stt_start_time),
stt_stop_time: this.toUnixTimestamp(stt_stop_time),
start_talking_time: this.toUnixTimestamp(start_talking_time),
stop_talking_time: this.toUnixTimestamp(stop_talking_time),
stt_latency: parseFloat((Math.abs(stt_on_transcription_time - stop_talking_time)) / 1000).toFixed(2),
stt_latency_ms: Math.abs(stt_on_transcription_time - stop_talking_time),
stt_usage: parseFloat((stt_stop_time - this.stt_start_time) / 1000).toFixed(2),
talkspurts: this.talkspurts.map((ts) =>
([this.toUnixTimestamp(ts.start || 0), this.toUnixTimestamp(ts.stop || 0)]))
};
}
resetTime() {
if (!this.isRunning) {
return;
}
this.stt_start_time = Date.now();
this.stt_stop_time = 0;
this.stt_on_transcription_time = 0;
this.clearTalkspurts();
this.logger.info('STT Latency Calculator reset');
}
onTranscriptionReceived() {
if (!this.isRunning) {
return;
}
this.stt_on_transcription_time = Date.now();
this.logger.debug(`CallSession:on-transcription set to ${this.stt_on_transcription_time}`);
}
onTranscribeStop() {
if (!this.isRunning) {
return;
}
this.stt_stop_time = Date.now();
this.logger.debug(`CallSession:transcribe-stop set to ${this.stt_stop_time}`);
}
clearTalkspurts() {
this.talkspurts = [];
if (!this.isInTalkSpurt) {
this.start_talking_time = 0;
}
}
}
module.exports = SttLatencyCalculator;

View File

@@ -110,6 +110,10 @@ const stickyVars = {
voxist: [
'VOXIST_API_KEY',
],
cartesia: [
'CARTESIA_API_KEY',
'CARTESIA_MODEL_ID'
],
speechmatics: [
'SPEECHMATICS_API_KEY',
'SPEECHMATICS_HOST',
@@ -117,7 +121,53 @@ const stickyVars = {
'SPEECHMATICS_SPEECH_HINTS',
'SPEECHMATICS_TRANSLATION_LANGUAGES',
'SPEECHMATICS_TRANSLATION_PARTIALS'
]
],
openai: [
'OPENAI_API_KEY',
'OPENAI_MODEL',
'OPENAI_INPUT_AUDIO_NOISE_REDUCTION',
'OPENAI_TURN_DETECTION_TYPE',
'OPENAI_TURN_DETECTION_THRESHOLD',
'OPENAI_TURN_DETECTION_PREFIX_PADDING_MS',
'OPENAI_TURN_DETECTION_SILENCE_DURATION_MS',
],
houndify: [
'HOUNDIFY_CLIENT_ID',
'HOUNDIFY_CLIENT_KEY',
'HOUNDIFY_USER_ID',
'HOUNDIFY_MAX_SILENCE_SECONDS',
'HOUNDIFY_MAX_SILENCE_AFTER_FULL_QUERY_SECONDS',
'HOUNDIFY_MAX_SILENCE_AFTER_PARTIAL_QUERY_SECONDS',
'HOUNDIFY_VAD_SENSITIVITY',
'HOUNDIFY_VAD_TIMEOUT',
'HOUNDIFY_VAD_MODE',
'HOUNDIFY_VAD_VOICE_MS',
'HOUNDIFY_VAD_SILENCE_MS',
'HOUNDIFY_VAD_DEBUG',
'HOUNDIFY_AUDIO_FORMAT',
'HOUNDIFY_ENABLE_NOISE_REDUCTION',
'HOUNDIFY_AUDIO_ENDPOINT',
'HOUNDIFY_ENABLE_PROFANITY_FILTER',
'HOUNDIFY_ENABLE_PUNCTUATION',
'HOUNDIFY_ENABLE_CAPITALIZATION',
'HOUNDIFY_CONFIDENCE_THRESHOLD',
'HOUNDIFY_ENABLE_DISFLUENCY_FILTER',
'HOUNDIFY_MAX_RESULTS',
'HOUNDIFY_ENABLE_WORD_TIMESTAMPS',
'HOUNDIFY_MAX_ALTERNATIVES',
'HOUNDIFY_PARTIAL_TRANSCRIPT_INTERVAL',
'HOUNDIFY_SESSION_TIMEOUT',
'HOUNDIFY_CONNECTION_TIMEOUT',
'HOUNDIFY_LATITUDE',
'HOUNDIFY_LONGITUDE',
'HOUNDIFY_CITY',
'HOUNDIFY_STATE',
'HOUNDIFY_COUNTRY',
'HOUNDIFY_TIMEZONE',
'HOUNDIFY_DOMAIN',
'HOUNDIFY_CUSTOM_VOCABULARY',
'HOUNDIFY_LANGUAGE_MODEL'
],
};
/**
@@ -326,6 +376,61 @@ const normalizeDeepgram = (evt, channel, language, shortUtterance) => {
};
};
const normalizeGladia = (evt, channel, language, shortUtterance) => {
const copy = JSON.parse(JSON.stringify(evt));
// Handle Gladia transcript format
if (evt.type === 'transcript' && evt.data && evt.data.utterance) {
const utterance = evt.data.utterance;
const alternatives = [{
confidence: utterance.confidence || 0,
transcript: utterance.text || '',
}];
return {
language_code: utterance.language || language,
channel_tag: channel,
is_final: evt.data.is_final || false,
alternatives,
vendor: {
name: 'gladia',
evt: copy
}
};
}
};
const normalizeDeepgramFlux = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
let turnTakingEvent;
if (['StartOfTurn', 'EagerEndOfTurn', 'TurnResumed', 'EndOfTurn'].includes(evt.event)) {
turnTakingEvent = evt.event;
}
/* calculate total confidence based on word-level confidence */
const realWords = (evt.words || [])
.filter((w) => ![',.!?;'].includes(w.word));
const confidence = realWords.length > 0 ? realWords.reduce((acc, w) => acc + w.confidence, 0) / realWords.length : 0;
return {
language_code: language,
channel_tag: channel,
is_final: evt.event === 'EndOfTurn',
alternatives: [
{
confidence,
end_of_turn_confidence: evt.end_of_turn_confidence,
transcript: evt.transcript,
...(turnTakingEvent && {turn_taking_event: turnTakingEvent})
}
],
vendor: {
name: 'deepgramflux',
evt: copy
}
};
};
const normalizeNvidia = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
const alternatives = (evt.alternatives || [])
@@ -510,16 +615,27 @@ const normalizeAws = (evt, channel, language) => {
const normalizeAssemblyAi = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
const alternatives = [];
let is_final = false;
if (evt.type && evt.type === 'Turn') {
// v3 is here
alternatives.push({
confidence: evt.end_of_turn_confidence,
transcript: evt.transcript,
});
is_final = evt.end_of_turn;
} else {
alternatives.push({
confidence: evt.confidence,
transcript: evt.text,
});
is_final = evt.message_type === 'FinalTranscript';
}
return {
language_code: language,
channel_tag: channel,
is_final: evt.message_type === 'FinalTranscript',
alternatives: [
{
confidence: evt.confidence,
transcript: evt.text,
}
],
is_final,
alternatives,
vendor: {
name: 'assemblyai',
evt: copy
@@ -527,6 +643,30 @@ const normalizeAssemblyAi = (evt, channel, language) => {
};
};
const normalizeHoundify = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
const alternatives = [];
const is_final = evt.ResultsAreFinal && evt.ResultsAreFinal[0] === true;
if (evt.Disambiguation && evt.Disambiguation.ChoiceData && evt.Disambiguation.ChoiceData.length > 0) {
// Handle Houndify Voice Search Result format
const choiceData = evt.Disambiguation.ChoiceData[0];
alternatives.push({
confidence: choiceData.ConfidenceScore || choiceData.ASRConfidence || 0.0,
transcript: choiceData.FormattedTranscription || choiceData.Transcription || '',
});
}
return {
language_code: language,
channel_tag: channel,
is_final,
alternatives,
vendor: {
name: 'houndify',
evt: copy
}
};
};
const normalizeVoxist = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
return {
@@ -546,6 +686,25 @@ const normalizeVoxist = (evt, channel, language) => {
};
};
const normalizeCartesia = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
return {
language_code: language,
channel_tag: channel,
is_final: evt.is_final,
alternatives: [
{
confidence: 1.00,
transcript: evt.text,
}
],
vendor: {
name: 'cartesia',
evt: copy
}
};
};
const normalizeSpeechmatics = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
const is_final = evt.message === 'AddTranscript';
@@ -571,6 +730,35 @@ const normalizeSpeechmatics = (evt, channel, language) => {
return obj;
};
const calculateConfidence = (logprobsArray) => {
// Sum the individual log probabilities
const totalLogProb = logprobsArray.reduce((sum, tokenInfo) => sum + tokenInfo.logprob, 0);
// Convert the total log probability back to a regular probability
const confidence = Math.exp(totalLogProb);
return confidence;
};
const normalizeOpenAI = (evt, channel, language) => {
const copy = JSON.parse(JSON.stringify(evt));
const obj = {
language_code: language,
channel_tag: channel,
is_final: true,
alternatives: [
{
transcript: evt.transcript,
confidence: evt.logprobs ? calculateConfidence(evt.logprobs) : 1.0,
}
],
vendor: {
name: 'openai',
evt: copy
}
};
return obj;
};
module.exports = (logger) => {
const normalizeTranscription = (evt, vendor, channel, language, shortUtterance, punctuation) => {
@@ -578,6 +766,10 @@ module.exports = (logger) => {
switch (vendor) {
case 'deepgram':
return normalizeDeepgram(evt, channel, language, shortUtterance);
case 'gladia':
return normalizeGladia(evt, channel, language, shortUtterance);
case 'deepgramflux':
return normalizeDeepgramFlux(evt, channel, language, shortUtterance);
case 'microsoft':
return normalizeMicrosoft(evt, channel, language, punctuation);
case 'google':
@@ -596,12 +788,18 @@ module.exports = (logger) => {
return normalizeCobalt(evt, channel, language);
case 'assemblyai':
return normalizeAssemblyAi(evt, channel, language, shortUtterance);
case 'houndify':
return normalizeHoundify(evt, channel, language, shortUtterance);
case 'voxist':
return normalizeVoxist(evt, channel, language);
case 'cartesia':
return normalizeCartesia(evt, channel, language);
case 'verbio':
return normalizeVerbio(evt, channel, language);
case 'speechmatics':
return normalizeSpeechmatics(evt, channel, language);
case 'openai':
return normalizeOpenAI(evt, channel, language);
default:
if (vendor.startsWith('custom:')) {
return normalizeCustom(evt, channel, language, vendor);
@@ -688,12 +886,15 @@ module.exports = (logger) => {
AWS_ACCESS_KEY_ID: sttCredentials.accessKeyId,
AWS_SECRET_ACCESS_KEY: sttCredentials.secretAccessKey,
AWS_REGION: sttCredentials.region,
AWS_SECURITY_TOKEN: sttCredentials.securityToken
AWS_SECURITY_TOKEN: sttCredentials.securityToken,
AWS_SESSION_TOKEN: sttCredentials.sessionToken ? sttCredentials.sessionToken : sttCredentials.securityToken
}),
...(awsOptions.accessKey && {AWS_ACCESS_KEY_ID: awsOptions.accessKey}),
...(awsOptions.secretKey && {AWS_SECRET_ACCESS_KEY: awsOptions.secretKey}),
...(awsOptions.region && {AWS_REGION: awsOptions.region}),
...(awsOptions.securityToken && {AWS_SECURITY_TOKEN: awsOptions.securityToken}),
...(awsOptions.sessionToken && {AWS_SESSION_TOKEN: awsOptions.sessionToken ?
awsOptions.sessionToken : awsOptions.securityToken}),
...(awsOptions.languageModelName && {AWS_LANGUAGE_MODEL_NAME: awsOptions.languageModelName}),
...(awsOptions.piiEntityTypes?.length && {AWS_PII_ENTITY_TYPES: awsOptions.piiEntityTypes.join(',')}),
...(awsOptions.piiIdentifyEntities && {AWS_PII_IDENTIFY_ENTITIES: true}),
@@ -789,11 +990,19 @@ module.exports = (logger) => {
};
}
else if ('deepgram' === vendor) {
let {model} = rOpts;
let model = rOpts.deepgramOptions?.model || rOpts.model || sttCredentials.model_id;
const {deepgramOptions = {}} = rOpts;
const deepgramUri = deepgramOptions.deepgramSttUri || sttCredentials.deepgram_stt_uri;
const useTls = deepgramOptions.deepgramSttUseTls || sttCredentials.deepgram_stt_use_tls;
// DH (2025-08-11) entity_prompt is currently limited to 100 words
const entityPrompt = deepgramOptions.entityPrompt ?
deepgramOptions.entityPrompt
.split(/\s+/)
.slice(0, 100)
.join(' ')
: undefined;
/* default to a sensible model if not supplied */
if (!model) {
model = selectDefaultDeepgramModel(task, language);
@@ -853,10 +1062,34 @@ module.exports = (logger) => {
{DEEPGRAM_SPEECH_FILLER_WORDS: deepgramOptions.fillerWords},
...((Array.isArray(deepgramOptions.keyterms) && deepgramOptions.keyterms.length > 0) &&
{DEEPGRAM_SPEECH_KEYTERMS: deepgramOptions.keyterms.join(',')}),
...(rOpts.altLanguages &&
{DEEPGRAM_SPEECH_ALTERNATIVE_LANGUAGE_CODES: [...new Set(rOpts.altLanguages)].join(',')}),
...(deepgramOptions.detect_language &&
{DEEPGRAM_DETECT_LANGUAGE: deepgramOptions.detect_language === true ? 'true' : 'false'}),
...(deepgramOptions.mipOptOut && {DEEPGRAM_SPEECH_MIP_OPT_OUT: deepgramOptions.mipOptOut}),
...(entityPrompt && {DEEPGRAM_SPEECH_ENTITY_PROMPT: entityPrompt}),
};
}
else if ('deepgramflux' === vendor) {
const {
eotThreshold,
eotTimeoutMs,
mipOptOut,
model,
eagerEotThreshold,
keyterms
} = rOpts.deepgramOptions || {};
opts = {
DEEPGRAMFLUX_API_KEY: sttCredentials.api_key,
DEEPGRAMFLUX_SPEECH_MODEL: model || 'flux-general-en',
...(eotThreshold && {DEEPGRAMFLUX_SPEECH_EOT_THRESHOLD: eotThreshold}),
...(eotTimeoutMs && {DEEPGRAMFLUX_SPEECH_EOT_TIMEOUT_MS: eotTimeoutMs}),
...(mipOptOut && {DEEPGRAMFLUX_SPEECH_MIP_OPT_OUT: mipOptOut}),
...(eagerEotThreshold && {DEEPGRAMFLUX_SPEECH_EAGER_EOT_THRESHOLD: eagerEotThreshold}),
...(keyterms && keyterms.length > 0 && {DEEPGRAMFLUX_SPEECH_KEYTERMS: keyterms.join(',')}),
};
}
else if ('gladia' === vendor) {
const {host, path} = sttCredentials;
opts = {
GLADIA_SPEECH_HOST: host,
GLADIA_SPEECH_PATH: path,
};
}
else if ('soniox' === vendor) {
@@ -957,14 +1190,85 @@ module.exports = (logger) => {
};
}
else if ('assemblyai' === vendor) {
const serviceVersion = rOpts.assemblyAiOptions?.serviceVersion || sttCredentials.service_version || 'v2';
const {
formatTurns,
endOfTurnConfidenceThreshold,
minEndOfTurnSilenceWhenConfident,
maxTurnSilence
} = rOpts.assemblyAiOptions || {};
opts = {
...opts,
ASSEMBLYAI_API_VERSION: serviceVersion,
...(serviceVersion === 'v3' && {
...(formatTurns && {
ASSEMBLYAI_FORMAT_TURNS: formatTurns
}),
...(endOfTurnConfidenceThreshold && {
ASSEMBLYAI_END_OF_TURN_CONFIDENCE_THRESHOLD: endOfTurnConfidenceThreshold
}),
ASSEMBLYAI_MIN_END_OF_TURN_SILENCE_WHEN_CONFIDENT: minEndOfTurnSilenceWhenConfident || 500,
...(maxTurnSilence && {
ASSEMBLYAI_MAX_TURN_SILENCE: maxTurnSilence
}),
}),
...(sttCredentials.api_key) &&
{ASSEMBLYAI_API_KEY: sttCredentials.api_key},
...(rOpts.hints?.length > 0 &&
{ASSEMBLYAI_WORD_BOOST: JSON.stringify(rOpts.hints)})
};
}
else if ('houndify' === vendor) {
const {
latitude, longitude, city, state, country, timeZone, domain, audioEndpoint,
maxSilenceSeconds, maxSilenceAfterFullQuerySeconds, maxSilenceAfterPartialQuerySeconds,
vadSensitivity, vadTimeout, vadMode, vadVoiceMs, vadSilenceMs, vadDebug,
audioFormat, enableNoiseReduction, enableProfanityFilter, enablePunctuation,
enableCapitalization, confidenceThreshold, enableDisfluencyFilter,
maxResults, enableWordTimestamps, maxAlternatives, partialTranscriptInterval,
sessionTimeout, connectionTimeout, customVocabulary, languageModel
} = rOpts.houndifyOptions || {};
opts = {
...opts,
HOUNDIFY_CLIENT_ID: sttCredentials.client_id,
HOUNDIFY_CLIENT_KEY: sttCredentials.client_key,
HOUNDIFY_USER_ID: sttCredentials.user_id,
HOUNDIFY_MAX_SILENCE_SECONDS: maxSilenceSeconds || 5,
HOUNDIFY_MAX_SILENCE_AFTER_FULL_QUERY_SECONDS: maxSilenceAfterFullQuerySeconds || 1,
HOUNDIFY_MAX_SILENCE_AFTER_PARTIAL_QUERY_SECONDS: maxSilenceAfterPartialQuerySeconds || 1.5,
...(vadSensitivity && {HOUNDIFY_VAD_SENSITIVITY: vadSensitivity}),
...(vadTimeout && {HOUNDIFY_VAD_TIMEOUT: vadTimeout}),
...(vadMode && {HOUNDIFY_VAD_MODE: vadMode}),
...(vadVoiceMs && {HOUNDIFY_VAD_VOICE_MS: vadVoiceMs}),
...(vadSilenceMs && {HOUNDIFY_VAD_SILENCE_MS: vadSilenceMs}),
...(vadDebug && {HOUNDIFY_VAD_DEBUG: vadDebug}),
...(audioFormat && {HOUNDIFY_AUDIO_FORMAT: audioFormat}),
...(enableNoiseReduction && {HOUNDIFY_ENABLE_NOISE_REDUCTION: enableNoiseReduction}),
...(enableProfanityFilter && {HOUNDIFY_ENABLE_PROFANITY_FILTER: enableProfanityFilter}),
...(enablePunctuation && {HOUNDIFY_ENABLE_PUNCTUATION: enablePunctuation}),
...(enableCapitalization && {HOUNDIFY_ENABLE_CAPITALIZATION: enableCapitalization}),
...(confidenceThreshold && {HOUNDIFY_CONFIDENCE_THRESHOLD: confidenceThreshold}),
...(enableDisfluencyFilter && {HOUNDIFY_ENABLE_DISFLUENCY_FILTER: enableDisfluencyFilter}),
...(maxResults && {HOUNDIFY_MAX_RESULTS: maxResults}),
...(enableWordTimestamps && {HOUNDIFY_ENABLE_WORD_TIMESTAMPS: enableWordTimestamps}),
...(maxAlternatives && {HOUNDIFY_MAX_ALTERNATIVES: maxAlternatives}),
...(partialTranscriptInterval && {HOUNDIFY_PARTIAL_TRANSCRIPT_INTERVAL: partialTranscriptInterval}),
...(sessionTimeout && {HOUNDIFY_SESSION_TIMEOUT: sessionTimeout}),
...(connectionTimeout && {HOUNDIFY_CONNECTION_TIMEOUT: connectionTimeout}),
...(latitude && {HOUNDIFY_LATITUDE: latitude}),
...(longitude && {HOUNDIFY_LONGITUDE: longitude}),
...(city && {HOUNDIFY_CITY: city}),
...(state && {HOUNDIFY_STATE: state}),
...(country && {HOUNDIFY_COUNTRY: country}),
...(timeZone && {HOUNDIFY_TIMEZONE: timeZone}),
...(domain && {HOUNDIFY_DOMAIN: domain}),
...(audioEndpoint && {HOUNDIFY_AUDIO_ENDPOINT: audioEndpoint}),
...(customVocabulary && {HOUNDIFY_CUSTOM_VOCABULARY:
Array.isArray(customVocabulary) ? customVocabulary.join(',') : customVocabulary}),
...(languageModel && {HOUNDIFY_LANGUAGE_MODEL: languageModel}),
};
}
else if ('voxist' === vendor) {
opts = {
...opts,
@@ -972,6 +1276,46 @@ module.exports = (logger) => {
{VOXIST_API_KEY: sttCredentials.api_key},
};
}
else if ('cartesia' === vendor) {
opts = {
...opts,
...(sttCredentials.api_key &&
{CARTESIA_API_KEY: sttCredentials.api_key}),
...(sttCredentials.stt_model_id && {
CARTESIA_MODEL_ID: sttCredentials.stt_model_id
})
};
}
else if ('openai' === vendor) {
const {openaiOptions = {}} = rOpts;
const model = openaiOptions.model || rOpts.model || sttCredentials.model_id || 'whisper-1';
const apiKey = openaiOptions.apiKey || sttCredentials.api_key;
opts = {
OPENAI_MODEL: model,
OPENAI_API_KEY: apiKey,
...opts,
...(openaiOptions.prompt && {OPENAI_PROMPT: openaiOptions.prompt}),
...(openaiOptions.input_audio_noise_reduction &&
{OPENAI_INPUT_AUDIO_NOISE_REDUCTION: openaiOptions.input_audio_noise_reduction}),
};
if (openaiOptions.turn_detection) {
opts = {
...opts,
OPENAI_TURN_DETECTION_TYPE: openaiOptions.turn_detection.type,
...(openaiOptions.turn_detection.threshold && {
OPENAI_TURN_DETECTION_THRESHOLD: openaiOptions.turn_detection.threshold
}),
...(openaiOptions.turn_detection.prefix_padding_ms && {
OPENAI_TURN_DETECTION_PREFIX_PADDING_MS: openaiOptions.turn_detection.prefix_padding_ms
}),
...(openaiOptions.turn_detection.silence_duration_ms && {
OPENAI_TURN_DETECTION_SILENCE_DURATION_MS: openaiOptions.turn_detection.silence_duration_ms
}),
};
}
}
else if ('verbio' === vendor) {
const {verbioOptions = {}} = rOpts;
opts = {

View File

@@ -8,7 +8,7 @@ const {
const MAX_CHUNK_SIZE = 1800;
const HIGH_WATER_BUFFER_SIZE = 1000;
const LOW_WATER_BUFFER_SIZE = 200;
const TIMEOUT_RETRY_MSECS = 3000;
const TIMEOUT_RETRY_MSECS = 1000; // 1 second
const isWhitespace = (str) => /^\s*$/.test(str);
@@ -80,7 +80,7 @@ class TtsStreamingBuffer extends Emitter {
clearTimeout(this.timer);
this.removeCustomEventListeners();
if (this.ep) {
this._api(this.ep, [this.ep.uuid, 'close'])
this._api(this.ep, [this.ep.uuid, 'stop'])
.catch((err) =>
this.logger.info({ err }, 'TtsStreamingBuffer:stop Error closing TTS streaming')
);
@@ -193,10 +193,7 @@ class TtsStreamingBuffer extends Emitter {
this.logger.debug('TtsStreamingBuffer:_feedQueue TTS stream is not open or no endpoint available');
return;
}
if (
this._connectionStatus === TtsStreamingConnectionStatus.NotConnected ||
this._connectionStatus === TtsStreamingConnectionStatus.Failed
) {
if (this._connectionStatus !== TtsStreamingConnectionStatus.Connected) {
this.logger.debug('TtsStreamingBuffer:_feedQueue TTS stream is not connected');
return;
}
@@ -278,6 +275,14 @@ class TtsStreamingBuffer extends Emitter {
}
const chunk = combinedText.slice(0, chunkEnd);
// Check if the chunk is only whitespace before processing the queue
// If so, wait for more meaningful text
if (isWhitespace(chunk)) {
this.logger.debug('TtsStreamingBuffer:_feedQueue chunk is only whitespace, waiting for more text');
this._setTimerIfNeeded();
return;
}
// Now we iterate over the queue items
// and deduct their lengths until we've accounted for chunkEnd characters.
let remaining = chunkEnd;
@@ -301,6 +306,14 @@ class TtsStreamingBuffer extends Emitter {
this.bufferedLength -= chunkEnd;
const modifiedChunk = chunk.replace(/\n\n/g, '\n \n');
if (isWhitespace(modifiedChunk)) {
this.logger.debug('TtsStreamingBuffer:_feedQueue modified chunk is only whitespace, restoring queue');
this.queue.unshift({ type: 'text', value: chunk });
this.bufferedLength += chunkEnd;
this._setTimerIfNeeded();
return;
}
this.logger.debug(`TtsStreamingBuffer:_feedQueue sending chunk to tts: ${modifiedChunk}`);
try {
@@ -349,6 +362,7 @@ class TtsStreamingBuffer extends Emitter {
if (this.queue.length > 0) {
await this._feedQueue();
}
this.emit(TtsStreamingEvents.Connected, { vendor });
}
_onConnectFailure(vendor) {
@@ -377,6 +391,7 @@ class TtsStreamingBuffer extends Emitter {
_onTimeout() {
this.logger.debug('TtsStreamingBuffer:_onTimeout Timeout waiting for sentence boundary');
this.timer = null;
// Check if new text has been added since the timer was set.
const now = Date.now();
if (now - this.lastUpdateTime < TIMEOUT_RETRY_MSECS) {
@@ -384,7 +399,6 @@ class TtsStreamingBuffer extends Emitter {
this._setTimerIfNeeded();
return;
}
this.timer = null;
this._feedQueue(true);
}
@@ -399,6 +413,7 @@ class TtsStreamingBuffer extends Emitter {
removeCustomEventListeners() {
this.eventHandlers.forEach((h) => h.ep.removeCustomEventListener(h.event, h.handler));
this.eventHandlers.length = 0;
}
_initHandlers(ep) {

View File

@@ -1,6 +1,7 @@
const assert = require('assert');
const BaseRequestor = require('./base-requestor');
const short = require('short-uuid');
const parseUrl = require('parse-url');
const {HookMsgTypes, WS_CLOSE_CODES} = require('./constants.json');
const Websocket = require('ws');
const snakeCaseKeys = require('./snakecase-keys');
@@ -41,6 +42,19 @@ class WsRequestor extends BaseRequestor {
assert(this._isAbsoluteUrl(this.url));
const parsedUrl = parseUrl(this.url);
const hash = parsedUrl.hash || '';
const hashObj = hash ? this._parseHashParams(hash) : {};
// remove hash
this.cleanUrl = hash ? this.url.replace(`#${hash}`, '') : this.url;
// Retry policy: rp valid values: 4xx, 5xx, ct, rt, all, default is ct
// Retry count: rc valid values: 1-5, default is 5 for websockets
this.maxReconnects = Math.min(Math.abs(parseInt(hashObj.rc) || MAX_RECONNECTS), 5);
this.retryPolicy = hashObj.rp || 'ct';
this.retryPolicyValues = this.retryPolicy.split(',').map((v) => v.trim());
this.on('socket-closed', this._onSocketClosed.bind(this));
}
@@ -55,7 +69,7 @@ class WsRequestor extends BaseRequestor {
* @param {string} [hook.password] - if basic auth is protecting the endpoint
* @param {object} [params] - request parameters
*/
async request(type, hook, params, httpHeaders = {}) {
async request(type, hook, params, httpHeaders = {}, span) {
assert(HookMsgTypes.includes(type));
const url = hook.url || hook;
const wantsAck = !MTYPE_WANTS_ACK.includes(type);
@@ -87,7 +101,7 @@ class WsRequestor extends BaseRequestor {
this.close();
this.emit('handover', requestor);
}
return requestor.request(type, hook, params, httpHeaders);
return requestor.request(type, hook, params, httpHeaders, span);
}
/* connect if necessary */
@@ -111,16 +125,56 @@ class WsRequestor extends BaseRequestor {
}
this.connectInProgress = true;
this.logger.debug(`WsRequestor:request(${this.id}) - connecting since we do not have a connection for ${type}`);
if (this.connections >= MAX_RECONNECTS) {
return Promise.reject(`max attempts connecting to ${this.url}`);
}
try {
const startAt = process.hrtime();
await this._connect();
const rtt = this._roundTrip(startAt);
this.stats.histogram('app.hook.connect_time', rtt, ['hook_type:app']);
let retryCount = 0;
let lastError = null;
while (retryCount <= this.maxReconnects) {
try {
this.logger.debug({retryCount, maxReconnects: this.maxReconnects},
'WsRequestor:request - attempting connection retry');
// Ensure clean state before each connection attempt
if (this.ws) {
this.ws.removeAllListeners();
this.ws = null;
}
const startAt = process.hrtime();
await this._connect();
const rtt = this._roundTrip(startAt);
this.stats.histogram('app.hook.connect_time', rtt, ['hook_type:app']);
lastError = null;
break;
} catch (error) {
lastError = error;
retryCount++;
if (retryCount <= this.maxReconnects &&
this.retryPolicyValues?.length &&
this._shouldRetry(error, this.retryPolicyValues)) {
const delay = this.backoffMs;
this.backoffMs = this.backoffMs < 2000 ? this.backoffMs * 2 : (this.backoffMs + 2000);
this.logger.debug({delay}, 'WsRequestor:request - waiting before retry');
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
this.logger.error({error: error.message, retryCount, maxReconnects: this.maxReconnects},
'WsRequestor:request - all connection attempts failed');
throw lastError;
}
}
// If we exit the loop without success, throw the last error
if (lastError) {
throw lastError;
}
} catch (err) {
this.logger.info({url, err}, 'WsRequestor:request - failed connecting');
this.logger.info({url, err, retryPolicy: this.retryPolicy},
'WsRequestor:request - all connection attempts failed');
this.connectInProgress = false;
return Promise.reject(err);
}
@@ -132,7 +186,7 @@ class WsRequestor extends BaseRequestor {
assert(this.ws);
/* prepare and send message */
let payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null;
let payload = params ? snakeCaseKeys(params, ['customerData', 'sip', 'env_vars', 'args']) : null;
if (type === 'session:new' || type === 'session:adulting') this._sessionData = payload;
if (type === 'session:reconnect') payload = this._sessionData;
assert.ok(url, 'WsRequestor:request url was not provided');
@@ -146,17 +200,23 @@ class WsRequestor extends BaseRequestor {
type,
msgid,
call_sid: this.call_sid,
hook: ['verb:hook', 'session:redirect', 'llm:event', 'llm:tool-call'].includes(type) ? url : undefined,
hook: [
'verb:hook', 'dial:confirm', 'session:redirect', 'llm:event', 'llm:tool-call'
].includes(type) ? url : undefined,
data: {...payload},
...b3
};
// add msgid to span attributes if it exists
if (span) {
span.setAttributes({'msgid': msgid});
}
const sendQueuedMsgs = () => {
if (this.queuedMsg.length > 0) {
for (const {type, hook, params, httpHeaders, promise} of this.queuedMsg) {
this.logger.debug(`WsRequestor:request - preparing queued ${type} for sending`);
if (promise) {
this.request(type, hook, params, httpHeaders)
this.request(type, hook, params, httpHeaders, span)
.then((res) => promise.resolve(res))
.catch((err) => promise.reject(err));
}
@@ -233,7 +293,7 @@ class WsRequestor extends BaseRequestor {
/* send the message */
this.ws.send(JSON.stringify(obj), async() => {
this.logger.debug({obj}, `WsRequestor:request websocket: sent (${url})`);
if (obj.type !== 'llm:event') this.logger.debug({obj}, `WsRequestor:request websocket: sent (${url})`);
// If session:reconnect is waiting for ack, hold here until ack to send queuedMsgs
if (this._reconnectPromise) {
try {
@@ -295,17 +355,23 @@ class WsRequestor extends BaseRequestor {
};
if (this.username && this.password) opts = {...opts, auth: `${this.username}:${this.password}`};
// Clean up any existing connection event listeners to prevent interference between retry attempts
this.removeAllListeners('ready');
this.removeAllListeners('not-ready');
this
.once('ready', (ws) => {
this.logger.debug('WsRequestor:_connect - ready event fired, resolving Promise');
this.removeAllListeners('not-ready');
if (this.connections > 1) this.request('session:reconnect', this.url);
resolve();
})
.once('not-ready', (err) => {
this.logger.error({err: err.message}, 'WsRequestor:_connect - not-ready event fired, rejecting Promise');
this.removeAllListeners('ready');
reject(err);
});
const ws = new Websocket(this.url, ['ws.jambonz.org'], opts);
const ws = new Websocket(this.cleanUrl, ['ws.jambonz.org'], opts);
this._setHandlers(ws);
});
}
@@ -329,10 +395,13 @@ class WsRequestor extends BaseRequestor {
}
_onError(err) {
if (this.connections > 0) {
this.logger.info({url: this.url, err}, 'WsRequestor:_onError');
if (this.connectInProgress) {
this.logger.info({url: this.url, err}, 'WsRequestor:_onError - emitting not-ready for connection attempt');
this.emit('not-ready', err);
}
else if (this.connections === 0) {
this.emit('not-ready', err);
}
else this.emit('not-ready', err);
}
_onOpen(ws) {
@@ -369,30 +438,44 @@ class WsRequestor extends BaseRequestor {
statusMessage: res.statusMessage
}, 'WsRequestor - unexpected response');
this.emit('connection-failure');
this.emit('not-ready', new Error(`${res.statusCode} ${res.statusMessage}`));
this.connections++;
const error = new Error(`${res.statusCode} ${res.statusMessage}`);
error.statusCode = res.statusCode;
this.connectInProgress = false;
this.emit('not-ready', error);
}
_onSocketClosed() {
this.ws = null;
this.emit('connection-dropped');
this._stopPingTimer();
if (this.connections > 0 && this.connections < MAX_RECONNECTS && !this.closedGracefully) {
if (this.connections > 0 && this.connections < this.maxReconnects && !this.closedGracefully) {
if (!this._initMsgId) this._clearPendingMessages();
this.logger.debug(`WsRequestor:_onSocketClosed waiting ${this.backoffMs} to reconnect`);
setTimeout(() => {
this._scheduleReconnect('_onSocketClosed');
}
}
_scheduleReconnect(source) {
this.logger.debug(`WsRequestor:_scheduleReconnect waiting ${this.backoffMs} to reconnect (${source})`);
setTimeout(() => {
this.logger.debug(
{haveWs: !!this.ws, connectInProgress: this.connectInProgress},
`WsRequestor:_scheduleReconnect time to reconnect (${source})`);
if (!this.ws && !this.connectInProgress) {
this.connectInProgress = true;
return this._connect()
.catch((err) => this.logger.error(`WsRequestor:${source} There is error while reconnect`, err))
.finally(() => this.connectInProgress = false);
} else {
this.logger.debug(
{haveWs: !!this.ws, connectInProgress: this.connectInProgress},
'WsRequestor:_onSocketClosed time to reconnect');
if (!this.ws && !this.connectInProgress) {
this.connectInProgress = true;
return this._connect()
.catch((err) => this.logger.error('WsRequestor:_onSocketClosed There is error while reconnect', err))
.finally(() => this.connectInProgress = false);
}
}, this.backoffMs);
this.backoffMs = this.backoffMs < 2000 ? this.backoffMs * 2 : (this.backoffMs + 2000);
}
`WsRequestor:_scheduleReconnect skipping reconnect attempt (${source}) - conditions not met`);
}
}, this.backoffMs);
this.backoffMs = this.backoffMs < 2000 ? this.backoffMs * 2 : (this.backoffMs + 2000);
}
_onMessage(content, isBinary) {

1751
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "jambonz-feature-server",
"version": "0.9.3",
"version": "0.9.5",
"main": "app.js",
"engines": {
"node": ">= 18.x"
@@ -27,14 +27,15 @@
"dependencies": {
"@aws-sdk/client-auto-scaling": "^3.549.0",
"@aws-sdk/client-sns": "^3.549.0",
"@jambonz/db-helpers": "^0.9.6",
"@jambonz/db-helpers": "^0.9.18",
"@jambonz/http-health-check": "^0.0.1",
"@jambonz/mw-registrar": "^0.2.7",
"@jambonz/realtimedb-helpers": "^0.8.13",
"@jambonz/speech-utils": "^0.2.3",
"@jambonz/realtimedb-helpers": "^0.8.15",
"@jambonz/speech-utils": "^0.2.26",
"@jambonz/stats-collector": "^0.1.10",
"@jambonz/verb-specifications": "^0.0.98",
"@jambonz/time-series": "^0.2.13",
"@jambonz/time-series": "^0.2.14",
"@jambonz/verb-specifications": "^0.0.119",
"@modelcontextprotocol/sdk": "^1.9.0",
"@opentelemetry/api": "^1.8.0",
"@opentelemetry/exporter-jaeger": "^1.23.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.50.0",
@@ -47,21 +48,19 @@
"bent": "^7.3.12",
"debug": "^4.3.4",
"deepcopy": "^2.1.0",
"drachtio-fsmrf": "^4.0.1",
"drachtio-srf": "^5.0.2",
"drachtio-fsmrf": "^4.1.2",
"drachtio-srf": "^5.0.14",
"express": "^4.19.2",
"express-validator": "^7.0.1",
"moment": "^2.30.1",
"parse-url": "^9.2.0",
"pino": "^8.20.0",
"pino": "^10.1.0",
"polly-ssml-split": "^0.1.0",
"proxyquire": "^2.1.3",
"sdp-transform": "^2.15.0",
"short-uuid": "^5.1.0",
"sinon": "^17.0.1",
"to-snake-case": "^1.0.0",
"undici": "^6.20.0",
"uuid-random": "^1.3.2",
"undici": "^7.5.0",
"verify-aws-sns-signature": "^0.1.0",
"ws": "^8.18.0",
"xml2js": "^0.6.2"
@@ -71,6 +70,7 @@
"eslint": "7.32.0",
"eslint-plugin-promise": "^6.1.1",
"nyc": "^15.1.0",
"proxyquire": "^2.1.3",
"tape": "^5.7.5"
},
"optionalDependencies": {

View File

@@ -0,0 +1,151 @@
// Test for HttpRequestor retry functionality
const test = require('tape');
const sinon = require('sinon');
const proxyquire = require('proxyquire').noCallThru();
const { createMocks, setupBaseRequestorMocks } = require('./utils/mock-helper');
// Create mocks
const mocks = createMocks();
// Mock timeSeries module
const timeSeriesMock = sinon.stub().returns(mocks.MockAlerter);
// Mock the config with required properties
const configMock = {
HTTP_POOL: '0',
HTTP_POOLSIZE: '10',
HTTP_PIPELINING: '1',
HTTP_TIMEOUT: 5000,
HTTP_PROXY_IP: null,
HTTP_PROXY_PORT: null,
HTTP_PROXY_PROTOCOL: null,
NODE_ENV: 'test',
HTTP_USER_AGENT_HEADER: 'test-agent'
};
// Mock db-helpers
const dbHelpersMock = mocks.MockDbHelpers;
// Require HttpRequestor with mocked dependencies
const BaseRequestor = proxyquire('../lib/utils/base-requestor', {
'@jambonz/time-series': timeSeriesMock,
'../config': configMock,
'../../': { srf: { locals: { stats: mocks.MockStats } } }
});
// Setup BaseRequestor mocks
setupBaseRequestorMocks(BaseRequestor);
// Require HttpRequestor with mocked dependencies
const HttpRequestor = proxyquire('../lib/utils/http-requestor', {
'./base-requestor': BaseRequestor,
'../config': configMock,
'@jambonz/db-helpers': dbHelpersMock
});
// Setup utility function
const setupRequestor = () => {
const hook = { url: 'http://localhost/test', method: 'POST' };
const requestor = new HttpRequestor(mocks.MockLogger, 'AC123', hook, 'testsecret');
requestor.stats = mocks.MockStats;
return requestor;
};
// Cleanup function for tests
const cleanup = (requestor) => {
sinon.restore();
if (requestor && requestor.close) requestor.close();
};
test('HttpRequestor: should retry on connection errors when specified in hash', async (t) => {
const requestor = setupRequestor();
// Setup a URL with retry params in the hash
const urlWithRetry = 'http://localhost/test#rc=3&rp=ct,5xx';
// First two calls fail with connection refused, third succeeds
const requestStub = sinon.stub(requestor.client, 'request');
const error = new Error('Connection refused');
error.code = 'ECONNREFUSED';
// Fail twice, succeed on third try
requestStub.onCall(0).rejects(error);
requestStub.onCall(1).rejects(error);
requestStub.onCall(2).resolves({
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: { json: async () => ({ success: true }) }
});
try {
const hook = { url: urlWithRetry, method: 'GET' };
const result = await requestor.request('verb:hook', hook, null);
t.equal(requestStub.callCount, 3, 'Should have retried twice for a total of 3 calls');
t.deepEqual(result, { success: true }, 'Should return successful response');
} catch (err) {
t.fail(`Should not throw an error: ${err.message}`);
}
cleanup(requestor);
t.end();
});
test('HttpRequestor: should respect retry count (rc) from hash', async (t) => {
const requestor = setupRequestor();
// Setup a URL with retry params in the hash - only retry once
const urlWithRetry = 'http://localhost/test#rc=1&rp=ct';
// All calls fail with connection refused
const requestStub = sinon.stub(requestor.client, 'request');
const error = new Error('Connection refused');
error.code = 'ECONNREFUSED';
// Always fail
requestStub.rejects(error);
try {
const hook = { url: urlWithRetry, method: 'GET' };
await requestor.request('verb:hook', hook, null);
t.fail('Should have thrown an error');
} catch (err) {
t.equal(requestStub.callCount, 2, 'Should have retried once for a total of 2 calls');
t.equal(err.code, 'ECONNREFUSED', 'Should throw the original error');
}
cleanup(requestor);
t.end();
});
test('HttpRequestor: should respect retry policy (rp) from hash', async (t) => {
const requestor = setupRequestor();
// Setup a URL with retry params in hash - only retry on 5xx errors
const urlWithRetry = 'http://localhost/test#rc=2&rp=5xx';
// Fail with 404 (should not retry since rp=5xx)
const requestStub = sinon.stub(requestor.client, 'request');
requestStub.resolves({
statusCode: 404,
headers: {},
body: {}
});
try {
const hook = { url: urlWithRetry, method: 'GET' };
await requestor.request('verb:hook', hook, null);
t.fail('Should have thrown an error');
} catch (err) {
t.equal(requestStub.callCount, 1, 'Should not retry on 404 when rp=5xx');
t.equal(err.statusCode, 404, 'Should throw 404 error');
}
cleanup(requestor);
t.end();
});
module.exports = {
setupRequestor,
cleanup
};

View File

@@ -0,0 +1,214 @@
const test = require('tape');
const sinon = require('sinon');
const { createMockedRequestors } = require('./utils/test-mocks');
// Use the shared mocks and helpers
const {
HttpRequestor,
setupRequestor,
cleanup
} = createMockedRequestors();
// All prototype overrides and setup are now handled in test-mocks.js
// --- TESTS ---
test('HttpRequestor: constructor sets up properties correctly', (t) => {
const requestor = setupRequestor();
t.equal(requestor.method, 'POST', 'method should be POST');
t.equal(requestor.url, 'http://localhost/test', 'url should be set');
t.equal(typeof requestor.client, 'object', 'client should be an object');
cleanup(requestor);
t.end();
});
test('HttpRequestor: constructor with username/password sets auth header', (t) => {
const { mocks, HttpRequestor } = createMockedRequestors();
const logger = mocks.logger;
const hook = {
url: 'http://localhost/test',
method: 'POST',
username: 'user',
password: 'pass'
};
const requestor = new HttpRequestor(logger, 'AC123', hook, 'secret');
t.ok(requestor.authHeader.Authorization, 'Authorization header should be set');
t.ok(requestor.authHeader.Authorization.startsWith('Basic '), 'Should be Basic auth');
cleanup(requestor);
t.end();
});
test('HttpRequestor: request should return JSON on 200 response', async (t) => {
const requestor = setupRequestor();
const expectedResponse = { success: true, data: [1, 2, 3] };
const fakeBody = { json: async () => expectedResponse };
sinon.stub(requestor.client, 'request').resolves({
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: fakeBody
});
try {
const hook = { url: 'http://localhost/test', method: 'POST' };
const result = await requestor.request('verb:hook', hook, { foo: 'bar' });
t.deepEqual(result, expectedResponse, 'Should return parsed JSON');
const requestCall = requestor.client.request.getCall(0);
const opts = requestCall.args[0];
t.equal(opts.method, 'POST', 'method should be POST');
t.ok(opts.headers['X-Signature'], 'Should include signature header');
t.ok(opts.body, 'Should include request body');
} catch (err) {
t.fail(err);
}
cleanup(requestor);
t.end();
});
test('HttpRequestor: request should handle non-200 responses', async (t) => {
const requestor = setupRequestor();
sinon.stub(requestor.client, 'request').resolves({
statusCode: 404,
headers: {},
body: {}
});
try {
const hook = { url: 'http://localhost/test', method: 'POST' };
await requestor.request('verb:hook', hook, { foo: 'bar' });
t.fail('Should have thrown an error');
} catch (err) {
t.ok(err, 'Should throw an error');
t.equal(err.statusCode, 404, 'Error should contain status code');
}
cleanup(requestor);
t.end();
});
test('HttpRequestor: request should handle ECONNREFUSED error', async (t) => {
const requestor = setupRequestor();
const error = new Error('Connection refused');
error.code = 'ECONNREFUSED';
sinon.stub(requestor.client, 'request').rejects(error);
try {
const hook = { url: 'http://localhost/test', method: 'POST' };
await requestor.request('verb:hook', hook, { foo: 'bar' });
t.fail('Should have thrown an error');
} catch (err) {
t.equal(err.code, 'ECONNREFUSED', 'Should pass through the error');
}
cleanup(requestor);
t.end();
});
test('HttpRequestor: request should skip jambonz:error type', async (t) => {
const requestor = setupRequestor();
const spy = sinon.spy(requestor.client, 'request');
const hook = { url: 'http://localhost/test', method: 'POST' };
const result = await requestor.request('jambonz:error', hook, { foo: 'bar' });
t.equal(result, undefined, 'Should return undefined');
t.equal(spy.callCount, 0, 'Should not call request method');
cleanup(requestor);
t.end();
});
test('HttpRequestor: request should handle array response', async (t) => {
const requestor = setupRequestor();
const fakeBody = { json: async () => [{ id: 1 }, { id: 2 }] };
sinon.stub(requestor.client, 'request').resolves({
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: fakeBody
});
try {
const hook = { url: 'http://localhost/test', method: 'POST' };
const result = await requestor.request('verb:hook', hook, { foo: 'bar' });
t.ok(Array.isArray(result), 'Should return an array');
t.equal(result.length, 2, 'Array should have 2 items');
} catch (err) {
t.fail(err);
}
cleanup(requestor);
t.end();
});
test('HttpRequestor: request should handle llm:tool-call type', async (t) => {
const requestor = setupRequestor();
const fakeBody = { json: async () => ({ result: 'tool output' }) };
sinon.stub(requestor.client, 'request').resolves({
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: fakeBody
});
try {
const hook = { url: 'http://localhost/test', method: 'POST' };
const result = await requestor.request('llm:tool-call', hook, { tool: 'test' });
t.deepEqual(result, { result: 'tool output' }, 'Should return the parsed JSON');
} catch (err) {
t.fail(err);
}
cleanup(requestor);
t.end();
});
test('HttpRequestor: close should close the client if not using pools', (t) => {
// Ensure HTTP_POOL is set to false to disable pool usage
const oldHttpPool = process.env.HTTP_POOL;
process.env.HTTP_POOL = '0';
const requestor = setupRequestor();
// Make sure _usePools is false
requestor._usePools = false;
// Replace the client.close with a spy function
const closeSpy = sinon.spy();
requestor.client.close = closeSpy;
// Set client.closed to false to ensure the condition is met
requestor.client.closed = false;
// Call close
requestor.close();
// Check if the spy was called
t.ok(closeSpy.calledOnce, 'Should call client.close');
// Restore HTTP_POOL
process.env.HTTP_POOL = oldHttpPool;
// Don't call cleanup(requestor) as it would try to call client.close again
sinon.restore();
t.end();
});
test('HttpRequestor: request should handle URLs with fragments', async (t) => {
const requestor = setupRequestor();
// Use the same host/port as the base client to avoid creating a new client
const urlWithFragment = 'http://localhost?param1=value1#rc=5&rp=4xx,5xx,ct';
const expectedResponse = { status: 'success' };
const fakeBody = { json: async () => expectedResponse };
// Stub the request method
const requestStub = sinon.stub(requestor.client, 'request').callsFake((opts) => {
return Promise.resolve({
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: fakeBody
});
});
try {
const hook = { url: urlWithFragment, method: 'GET' };
const result = await requestor.request('verb:hook', hook, null);
t.deepEqual(result, expectedResponse, 'Should return the parsed JSON response');
const requestCall = requestStub.getCall(0);
const opts = requestCall.args[0];
t.ok(opts.query && opts.query.param1 === 'value1', 'Query parameters should be parsed');
t.equal(opts.path, '/', 'Path should be extracted from URL');
t.notOk(opts.query && opts.query.rc, 'Fragment should not be included in query parameters');
} catch (err) {
t.fail(err);
}
cleanup(requestor);
t.end();
});
// test('HttpRequestor: request should handle URLs with query parameters', async (t) => {
// t.pass('Restored original require function');
// t.end();
// });

View File

@@ -1,4 +1,8 @@
require('./ws-requestor-retry-unit-test');
require('./test_ws_retry_comprehensive');
require('./ws-requestor-unit-test');
require('./http-requestor-retry-test');
require('./http-requestor-unit-test');
require('./unit-tests');
require('./docker_start');
require('./create-test-db');
@@ -12,6 +16,7 @@ require('./sip-request-tests');
require('./create-call-test');
require('./play-tests');
require('./sip-refer-tests');
require('./sip-refer-handler-tests');
require('./listen-tests');
require('./config-test');
require('./queue-test');

View File

@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="ISO-8859-1" ?>
<!DOCTYPE scenario SYSTEM "sipp.dtd">
<scenario name="UAS that accepts call and sends REFER">
<!-- Receive incoming INVITE -->
<recv request="INVITE" crlf="true">
<action>
<ereg regexp=".*" search_in="hdr" header="Subject:" assign_to="1" />
<ereg regexp=".*" search_in="hdr" header="From:" assign_to="2" />
</action>
</recv>
<!-- Send 180 Ringing -->
<send>
<![CDATA[
SIP/2.0 180 Ringing
[last_Via:]
[last_From:]
[last_To:];tag=[pid]SIPpTag01[call_number]
[last_Call-ID:]
[last_CSeq:]
[last_Record-Route:]
Subject:[$1]
Content-Length: 0
]]>
</send>
<!-- Send 200 OK with SDP -->
<send>
<![CDATA[
SIP/2.0 200 OK
[last_Via:]
[last_From:]
[last_To:];tag=[pid]SIPpTag01[call_number]
[last_Call-ID:]
[last_CSeq:]
[last_Record-Route:]
Subject:[$1]
Contact: <sip:[local_ip]:[local_port];transport=[transport]>
Content-Type: application/sdp
Content-Length: [len]
v=0
o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip]
s=-
c=IN IP[media_ip_type] [media_ip]
t=0 0
m=audio [media_port] RTP/AVP 0
a=rtpmap:0 PCMU/8000
]]>
</send>
<recv request="ACK" rtd="true" crlf="true">
<action>
<!-- Check if this is NOT the first call (tag ends with 012 or higher) -->
<ereg regexp="tag=1SIPpTag01[2-9]" search_in="hdr" header="To:" assign_to="3" />
<log message="Not first call check result: [$3]"/>
</action>
</recv>
<!-- Skip REFER if we found a non-first call tag -->
<nop next="skip_refer" test="3" value="" compare="not_equal">
<action>
<log message="Found non-first call tag [$3], skipping REFER"/>
</action>
</nop>
<!-- Wait a moment, then send REFER (only on first call) -->
<pause milliseconds="1000"/>
<nop>
<action>
<log message="Sending REFER for first call"/>
</action>
</nop>
<!-- Send REFER (only on first iteration) -->
<send retrans="500">
<![CDATA[
REFER sip:service@[remote_ip]:[remote_port] SIP/2.0
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
From: <sip:[local_ip]:[local_port]>;tag=[pid]SIPpTag01[call_number]
To: [$2]
[last_Call-ID:]
CSeq: 2 REFER
Contact: <sip:[local_ip]:[local_port];transport=[transport]>
Max-Forwards: 70
X-Call-Number: [call_number]
Refer-To: <sip:+15551234567@example.com>
Referred-By: <sip:[local_ip]:[local_port]>
Content-Length: 0
]]>
</send>
<!-- Expect 202 Accepted (only on first iteration) -->
<recv response="202"/>
<label id="skip_refer"/>
<!-- Wait for BYE from feature server -->
<recv request="BYE"/>
<!-- Send 200 OK to BYE -->
<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

@@ -0,0 +1,90 @@
const test = require('tape');
const { sippUac } = require('./sipp')('test_fs');
const bent = require('bent');
const getJSON = bent('json')
const clearModule = require('clear-module');
const {provisionCallHook} = require('./utils');
const { sleepFor } = require('../lib/utils/helpers');
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('when parent leg recvs REFER it should end the dial after adulting child leg', async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// wait for fs connected to drachtio server.
await sleepFor(1000);
// GIVEN
const from = "dial_refer_handler";
let verbs = [
{
"verb": "dial",
"callerId": from,
"actionHook": "/actionHook",
"referHook": "/referHook",
"anchorMedia": true,
"target": [
{
"type": "phone",
"number": "15083084809"
}
]
}
];
await provisionCallHook(from, verbs);
// THEN
//const p = sippUac('uas-dial.xml', '172.38.0.10', undefined, undefined, 2);
const p = sippUac('uas-dial-refer.xml', '172.38.0.10', undefined, undefined, 2);
await sleepFor(1000);
let account_sid = '622f62e4-303a-49f2-bbe0-eb1e1714e37a';
let post = bent('http://127.0.0.1:3000/', 'POST', 'json', 201);
post('v1/createCall', {
'account_sid':account_sid,
"call_hook": {
"url": "http://127.0.0.1:3100/",
"method": "POST",
},
"from": from,
"to": {
"type": "phone",
"number": "15583084808"
}});
await p;
// Verify that the referHook was called
const obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_referHook`);
t.ok(obj.body.from === from,
'dial-refer-handler: referHook was called with correct from');
t.ok(obj.body.refer_details && obj.body.refer_details.sip_refer_to,
'dial-refer-handler: refer_details included in referHook');
t.ok(obj.body.refer_details.refer_to_user === '+15551234567',
'dial-refer-handler: refer_to_user correctly parsed');
t.ok(obj.body.refer_details.referring_call_sid,
'dial-refer-handler: referring_call_sid included');
t.ok(obj.body.refer_details.referred_call_sid,
'dial-refer-handler: referred_call_sid included');
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});

View File

@@ -58,6 +58,46 @@ test('\'refer\' tests w/202 and NOTIFY', {timeout: 25000}, async(t) => {
}
});
test('\'refer\' tests tel:', {timeout: 25000}, async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');
try {
await connect(srf);
// GIVEN
const verbs = [
{
verb: 'say',
text: 'silence_stream://100'
},
{
verb: 'sip:refer',
referTo: 'tel:+1234567890',
actionHook: '/actionHook'
}
];
const noVerbs = [];
const from = 'refer_with_tel';
await provisionCallHook(from, verbs);
await provisionActionHook(from, noVerbs)
// THEN
await sippUac('uac-refer-with-notify.xml', '172.38.0.10', from);
t.pass('refer: successfully received 202 Accepted');
await sleepFor(1000);
const obj = await getJSON(`http:127.0.0.1:3100/lastRequest/${from}_actionHook`);
t.ok(obj.body.final_referred_call_status === 200, 'refer: successfully received NOTIFY with 200 OK');
// console.log(`obj: ${JSON.stringify(obj)}`);
disconnect();
} catch (err) {
console.log(`error received: ${err}`);
disconnect();
t.error(err);
}
});
test('\'refer\' tests w/202 but no NOTIFY', {timeout: 25000}, async(t) => {
clearModule.all();
const {srf, disconnect} = require('../app');

View File

@@ -0,0 +1,436 @@
const test = require('tape');
const sinon = require('sinon');
const proxyquire = require("proxyquire");
proxyquire.noCallThru();
const {
JAMBONES_LOGLEVEL,
} = require('../lib/config');
const logger = require('pino')({level: JAMBONES_LOGLEVEL});
// Mock WebSocket specifically for retry testing
class RetryMockWebSocket {
static retryScenarios = new Map();
static connectionAttempts = new Map();
static urlMapping = new Map(); // Maps cleanUrl -> originalUrl
constructor(url, protocols, options) {
this.url = url;
this.protocols = protocols;
this.options = options;
this.eventListeners = new Map();
// Extract scenario key from URL hash or use URL itself
this.scenarioKey = this.extractScenarioKey(url);
// Track connection attempts for this scenario
const attempts = RetryMockWebSocket.connectionAttempts.get(this.scenarioKey) || 0;
RetryMockWebSocket.connectionAttempts.set(this.scenarioKey, attempts + 1);
console.log(`RetryMockWebSocket: constructor for URL ${url}, scenarioKey="${this.scenarioKey}", attempt #${attempts + 1}`);
// Handle connection immediately
setImmediate(() => {
this.handleConnection();
});
}
extractScenarioKey(url) {
console.log(`RetryMockWebSocket: extractScenarioKey from URL: ${url}`);
// Check if we have a mapping from cleanUrl to originalUrl
const originalUrl = RetryMockWebSocket.urlMapping.get(url);
if (originalUrl && originalUrl.includes('#')) {
const hash = originalUrl.split('#')[1];
console.log(`RetryMockWebSocket: found mapped URL with hash: ${hash}`);
return hash;
}
// For URLs with hash parameters, use the hash as the scenario key
if (url.includes('#')) {
const hash = url.split('#')[1];
console.log(`RetryMockWebSocket: found hash: ${hash}`);
return hash; // Use hash as scenario key
}
console.log(`RetryMockWebSocket: using full URL as scenario key: ${url}`);
return url; // Fallback to full URL
}
static setRetryScenario(key, scenario) {
console.log(`RetryMockWebSocket: setting scenario for key "${key}":`, scenario);
RetryMockWebSocket.retryScenarios.set(key, scenario);
}
static setUrlMapping(cleanUrl, originalUrl) {
console.log(`RetryMockWebSocket: mapping ${cleanUrl} -> ${originalUrl}`);
RetryMockWebSocket.urlMapping.set(cleanUrl, originalUrl);
}
static clearScenarios() {
console.log('RetryMockWebSocket: clearing all scenarios');
RetryMockWebSocket.retryScenarios.clear();
RetryMockWebSocket.connectionAttempts.clear();
RetryMockWebSocket.urlMapping.clear();
}
static getConnectionAttempts(key) {
return RetryMockWebSocket.connectionAttempts.get(key) || 0;
}
handleConnection() {
const scenario = RetryMockWebSocket.retryScenarios.get(this.scenarioKey);
console.log(`RetryMockWebSocket: handleConnection for scenarioKey="${this.scenarioKey}", scenario found:`, !!scenario);
if (!scenario) {
// Default successful connection
console.log(`RetryMockWebSocket: no scenario found, defaulting to success`);
this.simulateOpen();
return;
}
const attemptNumber = RetryMockWebSocket.connectionAttempts.get(this.scenarioKey);
const behavior = scenario.attempts[attemptNumber - 1] || scenario.attempts[scenario.attempts.length - 1];
console.log(`RetryMockWebSocket: attempt ${attemptNumber}, behavior:`, behavior);
if (behavior.type === 'handshake-failure') {
// Simulate handshake failure with specific status code
setImmediate(() => {
console.log(`RetryMockWebSocket: triggering handshake failure with status ${behavior.statusCode}`);
if (this.eventListeners.has('unexpected-response')) {
const mockResponse = {
statusCode: behavior.statusCode || 500,
statusMessage: behavior.statusMessage || 'Internal Server Error',
headers: {}
};
const mockRequest = {
headers: {}
};
this.eventListeners.get('unexpected-response')(mockRequest, mockResponse);
}
});
} else if (behavior.type === 'network-error') {
// Simulate network error during connection
setImmediate(() => {
console.log(`RetryMockWebSocket: triggering network error: ${behavior.message}`);
if (this.eventListeners.has('error')) {
const error = new Error(behavior.message || 'Network error');
// Set proper error code for retry policy checking
if (behavior.message && behavior.message.includes('Connection refused')) {
error.code = 'ECONNREFUSED';
} else if (behavior.message && behavior.message.includes('timeout')) {
error.code = 'ETIMEDOUT';
} else {
error.code = 'ECONNREFUSED'; // Default for network errors
}
this.eventListeners.get('error')(error);
}
});
} else if (behavior.type === 'success') {
// Successful connection
console.log(`RetryMockWebSocket: triggering success`);
this.simulateOpen();
}
}
simulateOpen() {
setImmediate(() => {
if (this.eventListeners.has('open')) {
console.log(`RetryMockWebSocket: calling open listener`);
this.eventListeners.get('open')();
}
});
}
once(event, listener) {
console.log(`RetryMockWebSocket: registering once listener for ${event}`);
this.eventListeners.set(event, listener);
return this;
}
on(event, listener) {
console.log(`RetryMockWebSocket: registering on listener for ${event}`);
this.eventListeners.set(event, listener);
return this;
}
removeAllListeners() {
this.eventListeners.clear();
}
send(data, callback) {
// For successful connections, simulate message response
try {
const json = JSON.parse(data);
console.log({json}, 'RetryMockWebSocket: got message from ws-requestor');
// Simulate successful response
setTimeout(() => {
const msg = {
type: 'ack',
msgid: json.msgid,
command: 'command',
call_sid: json.call_sid,
queueCommand: false,
data: '[{"verb": "play","url": "silence_stream://5000"}]'
};
console.log({msg}, 'RetryMockWebSocket: sending ack to ws-requestor');
this.mockOnMessage(JSON.stringify(msg));
}, 50);
if (callback) callback();
} catch (err) {
console.error('RetryMockWebSocket: Error processing send', err);
if (callback) callback(err);
}
}
mockOnMessage(message, isBinary = false) {
if (this.eventListeners.has('message')) {
this.eventListeners.get('message')(message, isBinary);
}
}
close(code) {
if (this.eventListeners.has('close')) {
this.eventListeners.get('close')(code || 1000);
}
}
}
const BaseRequestor = proxyquire('../lib/utils/base-requestor', {
'../../': {
srf: {
locals: {
stats: {
histogram: () => {},
},
},
},
},
'@jambonz/time-series': sinon.stub(),
});
const WsRequestor = proxyquire('../lib/utils/ws-requestor', {
'./base-requestor': BaseRequestor,
ws: RetryMockWebSocket,
});
test('ws retry policy - 4xx error with rp=5xx should not retry', async(t) => {
// GIVEN
console.log('Starting test setup...');
RetryMockWebSocket.clearScenarios();
const call_sid = 'ws_no_retry_4xx';
// Set up the URL mapping
const cleanUrl = 'ws://localhost:3000';
const originalUrl = 'ws://localhost:3000#rc=2&rp=5xx';
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
// Set up the retry scenario for the first attempt to fail with 400, but policy only retries 5xx
RetryMockWebSocket.setRetryScenario('rc=2&rp=5xx', {
attempts: [
{ type: 'handshake-failure', statusCode: 400, statusMessage: 'Bad Request' }
]
});
const hook = {
url: 'ws://localhost:3000#rc=2&rp=5xx', // Max 2 retries, retry only on 5xx
username: 'username',
password: 'password',
};
const params = {
callSid: call_sid,
};
// WHEN
const requestor = new WsRequestor(
logger,
'account_sid',
hook,
'webhook_secret'
);
try {
const result = await requestor.request('session:new', hook, params, {});
t.fail('Should have thrown an error');
t.end();
} catch (err) {
// THEN
const errorMessage = err.message || err.toString() || String(err);
t.ok(
errorMessage.includes('400'),
`ws properly failed without retry for 4xx when rp=5xx - error: ${errorMessage}`
);
t.end();
}
});
test('ws retry policy - 5xx error with rp=5xx should retry and succeed', async(t) => {
// GIVEN
console.log('Starting 5xx retry test setup...');
RetryMockWebSocket.clearScenarios();
const call_sid = 'ws_retry_5xx_success';
// Set up the URL mapping
const cleanUrl = 'ws://localhost:3000';
const originalUrl = 'ws://localhost:3000#rc=2&rp=5xx';
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
// Set up the retry scenario - first attempt fails with 500, second succeeds
RetryMockWebSocket.setRetryScenario('rc=2&rp=5xx', {
attempts: [
{ type: 'handshake-failure', statusCode: 500, statusMessage: 'Internal Server Error' },
{ type: 'success' }
]
});
const hook = {
url: 'ws://localhost:3000#rc=2&rp=5xx', // Max 2 retries, retry only on 5xx
username: 'username',
password: 'password',
};
const params = {
callSid: call_sid,
};
// WHEN
const requestor = new WsRequestor(
logger,
'account_sid',
hook,
'webhook_secret'
);
try {
const result = await requestor.request('session:new', hook, params, {});
// THEN
t.ok(result, 'ws successfully retried and connected after 5xx error');
// Verify that exactly 2 attempts were made
const attempts = RetryMockWebSocket.getConnectionAttempts('rc=2&rp=5xx');
t.equal(attempts, 2, 'Should have made exactly 2 connection attempts');
t.end();
} catch (err) {
t.fail(`Should have succeeded after retry - error: ${err.message}`);
t.end();
}
});
test('ws retry policy - network error with rp=ct should retry and succeed', async(t) => {
// GIVEN
console.log('Starting network error retry test setup...');
RetryMockWebSocket.clearScenarios();
const call_sid = 'ws_retry_network_success';
// Set up the URL mapping
const cleanUrl = 'ws://localhost:3000';
const originalUrl = 'ws://localhost:3000#rc=3&rp=ct';
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
// Set up the retry scenario - first two attempts fail with network error, third succeeds
RetryMockWebSocket.setRetryScenario('rc=3&rp=ct', {
attempts: [
{ type: 'network-error', message: 'Connection refused' },
{ type: 'network-error', message: 'Connection refused' },
{ type: 'success' }
]
});
const hook = {
url: 'ws://localhost:3000#rc=3&rp=ct', // Max 3 retries, retry on connection errors
username: 'username',
password: 'password',
};
const params = {
callSid: call_sid,
};
// WHEN
const requestor = new WsRequestor(
logger,
'account_sid',
hook,
'webhook_secret'
);
try {
const result = await requestor.request('session:new', hook, params, {});
// THEN
t.ok(result, 'ws successfully retried and connected after network errors');
// Verify that exactly 3 attempts were made
const attempts = RetryMockWebSocket.getConnectionAttempts('rc=3&rp=ct');
t.equal(attempts, 3, 'Should have made exactly 3 connection attempts');
t.end();
} catch (err) {
t.fail(`Should have succeeded after retry - error: ${err.message}`);
t.end();
}
});
test('ws retry policy - retry exhaustion should fail with last error', async(t) => {
// GIVEN
console.log('Starting retry exhaustion test setup...');
RetryMockWebSocket.clearScenarios();
const call_sid = 'ws_retry_exhaustion';
// Set up the URL mapping
const cleanUrl = 'ws://localhost:3000';
const originalUrl = 'ws://localhost:3000#rc=2&rp=5xx';
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
// Set up the retry scenario - all attempts fail with 500
RetryMockWebSocket.setRetryScenario('rc=2&rp=5xx', {
attempts: [
{ type: 'handshake-failure', statusCode: 500, statusMessage: 'Internal Server Error' },
{ type: 'handshake-failure', statusCode: 500, statusMessage: 'Internal Server Error' },
{ type: 'handshake-failure', statusCode: 500, statusMessage: 'Internal Server Error' }
]
});
const hook = {
url: 'ws://localhost:3000#rc=2&rp=5xx', // Max 2 retries, retry only on 5xx
username: 'username',
password: 'password',
};
const params = {
callSid: call_sid,
};
// WHEN
const requestor = new WsRequestor(
logger,
'account_sid',
hook,
'webhook_secret'
);
try {
const result = await requestor.request('session:new', hook, params, {});
t.fail('Should have thrown an error after exhausting retries');
t.end();
} catch (err) {
// THEN
const errorMessage = err.message || err.toString() || String(err);
t.ok(
errorMessage.includes('500'),
`ws properly failed after exhausting retries - error: ${errorMessage}`
);
// Verify that exactly 3 attempts were made (initial + 2 retries)
const attempts = RetryMockWebSocket.getConnectionAttempts('rc=2&rp=5xx');
t.equal(attempts, 3, 'Should have made exactly 3 connection attempts (initial + 2 retries)');
t.end();
}
});

103
test/utils/mock-helper.js Normal file
View File

@@ -0,0 +1,103 @@
const sinon = require('sinon');
/**
* Creates mock objects commonly needed for testing HttpRequestor and related classes
* @returns {Object} Mock objects
*/
const createMocks = () => {
// Basic logger mock
const MockLogger = {
debug: () => {},
info: () => {},
error: () => {}
};
// Stats mock
const MockStats = {
histogram: () => {}
};
// Alerter mock
const MockAlerter = {
AlertType: {
WEBHOOK_CONNECTION_FAILURE: 'WEBHOOK_CONNECTION_FAILURE',
WEBHOOK_STATUS_FAILURE: 'WEBHOOK_STATUS_FAILURE'
},
writeAlerts: async () => {}
};
// DB helpers mock
const MockDbHelpers = {
pool: {
getConnection: () => Promise.resolve({
connect: () => {},
on: () => {},
query: (sql, cb) => {
if (typeof cb === 'function') cb(null, []);
return { stream: () => ({ on: () => {} }) };
},
end: () => {}
}),
query: (...args) => {
const cb = args[args.length - 1];
if (typeof cb === 'function') cb(null, []);
return Promise.resolve([]);
}
},
camelize: (obj) => obj
};
// Time series mock
const MockTimeSeries = () => ({
writeAlerts: async () => {},
AlertType: {
WEBHOOK_CONNECTION_FAILURE: 'WEBHOOK_CONNECTION_FAILURE',
WEBHOOK_STATUS_FAILURE: 'WEBHOOK_STATUS_FAILURE'
}
});
return {
MockLogger,
MockStats,
MockAlerter,
MockDbHelpers,
MockTimeSeries
};
};
/**
* Set up mocks on the BaseRequestor class for tests
* @param {Object} BaseRequestor - The BaseRequestor class
*/
const setupBaseRequestorMocks = (BaseRequestor) => {
BaseRequestor.prototype._isAbsoluteUrl = function(url) { return url.startsWith('http'); };
BaseRequestor.prototype._isRelativeUrl = function(url) { return !url.startsWith('http'); };
BaseRequestor.prototype._generateSigHeader = function() { return { 'X-Signature': 'test-signature' }; };
BaseRequestor.prototype._roundTrip = function() { return 10; };
// Define baseUrl property
Object.defineProperty(BaseRequestor.prototype, 'baseUrl', {
get: function() { return 'http://localhost'; }
});
// Define Alerter property
const mocks = createMocks();
Object.defineProperty(BaseRequestor.prototype, 'Alerter', {
get: function() { return mocks.MockAlerter; }
});
};
/**
* Clean up after tests
* @param {Object} requestor - The requestor instance to clean up
*/
const cleanup = (requestor) => {
sinon.restore();
if (requestor && requestor.close) requestor.close();
};
module.exports = {
createMocks,
setupBaseRequestorMocks,
cleanup
};

154
test/utils/test-mocks.js Normal file
View File

@@ -0,0 +1,154 @@
/**
* Common test mocks for Jambonz tests
*/
const proxyquire = require('proxyquire').noCallThru();
// Logger mock
class MockLogger {
debug() {}
info() {}
error() {}
}
// Stats mock
const statsMock = { histogram: () => {} };
// Time series mock
const timeSeriesMock = () => ({
writeAlerts: async () => {},
AlertType: {
WEBHOOK_CONNECTION_FAILURE: 'WEBHOOK_CONNECTION_FAILURE',
WEBHOOK_STATUS_FAILURE: 'WEBHOOK_STATUS_FAILURE'
}
});
// DB helpers mock
const dbHelpersMock = {
pool: {
getConnection: () => Promise.resolve({
connect: () => {},
on: () => {},
query: (sql, cb) => {
if (typeof cb === 'function') cb(null, []);
return { stream: () => ({ on: () => {} }) };
},
end: () => {}
}),
query: (...args) => {
const cb = args[args.length - 1];
if (typeof cb === 'function') cb(null, []);
return Promise.resolve([]);
}
},
camelize: (obj) => obj
};
// Config mock
const configMock = {
HTTP_POOL: '0',
HTTP_POOLSIZE: '10',
HTTP_PIPELINING: '1',
HTTP_TIMEOUT: 5000,
HTTP_PROXY_IP: null,
HTTP_PROXY_PORT: null,
HTTP_PROXY_PROTOCOL: null,
NODE_ENV: 'test',
HTTP_USER_AGENT_HEADER: 'test-agent',
JAMBONES_TIME_SERIES_HOST: 'localhost'
};
// SRF mock
const srfMock = {
srf: {
locals: {
stats: statsMock
}
}
};
// Alerter mock
const alerterMock = {
AlertType: {
WEBHOOK_CONNECTION_FAILURE: 'WEBHOOK_CONNECTION_FAILURE',
WEBHOOK_STATUS_FAILURE: 'WEBHOOK_STATUS_FAILURE'
},
writeAlerts: async () => {}
};
/**
* Creates mocked BaseRequestor and HttpRequestor classes
* @returns {Object} Mocked classes and helper functions
*/
function createMockedRequestors() {
// First, mock BaseRequestor's dependencies
const BaseRequestor = proxyquire('../../lib/utils/base-requestor', {
'@jambonz/time-series': timeSeriesMock,
'../config': configMock,
'../../': srfMock
});
// Apply prototype methods and properties
BaseRequestor.prototype._isAbsoluteUrl = function(url) { return url.startsWith('http'); };
BaseRequestor.prototype._isRelativeUrl = function(url) { return !url.startsWith('http'); };
BaseRequestor.prototype._generateSigHeader = function() { return { 'X-Signature': 'test-signature' }; };
BaseRequestor.prototype._roundTrip = function() { return 10; };
// Define baseUrl property
Object.defineProperty(BaseRequestor.prototype, 'baseUrl', {
get: function() { return 'http://localhost'; }
});
// Define Alerter property
Object.defineProperty(BaseRequestor.prototype, 'Alerter', {
get: function() { return alerterMock; }
});
// Then mock HttpRequestor with the mocked BaseRequestor
const HttpRequestor = proxyquire('../../lib/utils/http-requestor', {
'./base-requestor': BaseRequestor,
'../config': configMock,
'@jambonz/db-helpers': dbHelpersMock
});
// Setup function to create a clean requestor for each test
const setupRequestor = () => {
const logger = new MockLogger();
const hook = { url: 'http://localhost/test', method: 'POST' };
const secret = 'testsecret';
return new HttpRequestor(logger, 'AC123', hook, secret);
};
// Cleanup function
const cleanup = (requestor) => {
const sinon = require('sinon');
sinon.restore();
if (requestor && requestor.close) requestor.close();
};
return {
BaseRequestor,
HttpRequestor,
setupRequestor,
cleanup,
mocks: {
logger: new MockLogger(),
stats: statsMock,
timeSeries: timeSeriesMock,
dbHelpers: dbHelpersMock,
config: configMock,
srf: srfMock,
alerter: alerterMock
}
};
}
module.exports = {
createMockedRequestors,
MockLogger,
statsMock,
timeSeriesMock,
dbHelpersMock,
configMock,
srfMock,
alerterMock
};

View File

@@ -99,6 +99,24 @@ app.post('/actionHook', (req, res) => {
return res.sendStatus(200);
});
/*
* referHook
*/
app.post('/referHook', (req, res) => {
console.log({payload: req.body}, 'POST /referHook');
let key = req.body.from + "_referHook"
addRequestToMap(key, req, hook_mapping);
return res.json([{"verb": "pause", "length": 2}]);
});
/*
* adultingHook
*/
app.post('/adulting', (req, res) => {
console.log({payload: req.body}, 'POST /adulting');
return res.sendStatus(200);
});
/*
* customHook
* For the hook to return

View File

@@ -83,7 +83,8 @@ test('invalid jambonz json create alert tests', async(t) => {
{account_sid: 'bb845d4b-83a9-4cde-a6e9-50f3743bab3f', page: 1, page_size: 25, days: 7});
let checked = false;
for (let i = 0; i < data.total; i++) {
checked = data.data[i].message === 'malformed jambonz payload: must be array'
checked = data.data[i].message === 'malformed jambonz payload: must be array';
if (checked) break;
}
t.ok(checked, 'alert is raised as expected');
disconnect();

View File

@@ -0,0 +1,605 @@
const test = require('tape');
const sinon = require('sinon');
const proxyquire = require("proxyquire");
proxyquire.noCallThru();
const {
JAMBONES_LOGLEVEL,
} = require('../lib/config');
const logger = require('pino')({level: JAMBONES_LOGLEVEL});
// Mock WebSocket specifically for retry testing
class RetryMockWebSocket {
static retryScenarios = new Map();
static connectionAttempts = new Map();
static urlMapping = new Map(); // Maps cleanUrl -> originalUrl
constructor(url, protocols, options) {
this.url = url;
this.protocols = protocols;
this.options = options;
this.eventListeners = new Map();
// Extract scenario key from URL hash or use URL itself
this.scenarioKey = this.extractScenarioKey(url);
// Track connection attempts for this scenario
const attempts = RetryMockWebSocket.connectionAttempts.get(this.scenarioKey) || 0;
RetryMockWebSocket.connectionAttempts.set(this.scenarioKey, attempts + 1);
// Handle connection immediately
setImmediate(() => {
this.handleConnection();
});
}
extractScenarioKey(url) {
console.log(`RetryMockWebSocket: extractScenarioKey from URL: ${url}`);
// Check if we have a mapping from cleanUrl to originalUrl
const originalUrl = RetryMockWebSocket.urlMapping.get(url);
if (originalUrl && originalUrl.includes('#')) {
const hash = originalUrl.split('#')[1];
console.log(`RetryMockWebSocket: found mapped URL with hash: ${hash}`);
return hash;
}
// For URLs with hash parameters, use the hash as the scenario key
if (url.includes('#')) {
const hash = url.split('#')[1];
console.log(`RetryMockWebSocket: found hash: ${hash}`);
return hash; // Use hash as scenario key
}
console.log(`RetryMockWebSocket: using full URL as scenario key: ${url}`);
return url; // Fallback to full URL
}
static setRetryScenario(key, scenario) {
RetryMockWebSocket.retryScenarios.set(key, scenario);
}
static setUrlMapping(cleanUrl, originalUrl) {
RetryMockWebSocket.urlMapping.set(cleanUrl, originalUrl);
}
static clearScenarios() {
RetryMockWebSocket.retryScenarios.clear();
RetryMockWebSocket.connectionAttempts.clear();
RetryMockWebSocket.urlMapping.clear();
}
static getConnectionAttempts(key) {
return RetryMockWebSocket.connectionAttempts.get(key) || 0;
}
handleConnection() {
const scenario = RetryMockWebSocket.retryScenarios.get(this.scenarioKey);
console.log(`RetryMockWebSocket: handleConnection for scenarioKey="${this.scenarioKey}", scenario found:`, !!scenario);
if (!scenario) {
// Default successful connection
this.simulateOpen();
return;
}
const attemptNumber = RetryMockWebSocket.connectionAttempts.get(this.scenarioKey);
const behavior = scenario.attempts[attemptNumber - 1] || scenario.attempts[scenario.attempts.length - 1];
console.log(`RetryMockWebSocket: attempt ${attemptNumber}, behavior:`, behavior);
if (behavior.type === 'handshake-failure') {
// Simulate handshake failure with specific status code
setImmediate(() => {
console.log(`RetryMockWebSocket: triggering handshake failure with status ${behavior.statusCode}`);
if (this.eventListeners.has('unexpected-response')) {
const mockResponse = {
statusCode: behavior.statusCode || 500,
statusMessage: behavior.statusMessage || 'Internal Server Error',
headers: {}
};
const mockRequest = {
headers: {}
};
this.eventListeners.get('unexpected-response')(mockRequest, mockResponse);
}
});
} else if (behavior.type === 'network-error') {
// Simulate network error during connection
setImmediate(() => {
console.log(`RetryMockWebSocket: triggering network error: ${behavior.message}`);
if (this.eventListeners.has('error')) {
const err = new Error(behavior.message || 'Network error');
// Set appropriate error codes based on the message
if (behavior.message === 'Connection timeout') {
err.code = 'ETIMEDOUT';
} else if (behavior.message === 'Connection refused') {
err.code = 'ECONNREFUSED';
} else if (behavior.message === 'Connection reset') {
err.code = 'ECONNRESET';
} else {
// Default to ECONNREFUSED for generic network errors
err.code = 'ECONNREFUSED';
}
this.eventListeners.get('error')(err);
}
});
} else if (behavior.type === 'success') {
// Successful connection
console.log(`RetryMockWebSocket: triggering success`);
this.simulateOpen();
}
}
simulateOpen() {
setImmediate(() => {
if (this.eventListeners.has('open')) {
this.eventListeners.get('open')();
}
});
}
once(event, listener) {
this.eventListeners.set(event, listener);
return this;
}
on(event, listener) {
this.eventListeners.set(event, listener);
return this;
}
removeAllListeners() {
this.eventListeners.clear();
}
send(data, callback) {
// For successful connections, simulate message response
try {
const json = JSON.parse(data);
console.log({json}, 'RetryMockWebSocket: got message from ws-requestor');
// Simulate successful response
setTimeout(() => {
const msg = {
type: 'ack',
msgid: json.msgid,
command: 'command',
call_sid: json.call_sid,
queueCommand: false,
data: '[{"verb": "play","url": "silence_stream://5000"}]'
};
console.log({msg}, 'RetryMockWebSocket: sending ack to ws-requestor');
this.mockOnMessage(JSON.stringify(msg));
}, 50);
if (callback) callback();
} catch (err) {
console.error('RetryMockWebSocket: Error processing send', err);
if (callback) callback(err);
}
}
mockOnMessage(message, isBinary = false) {
if (this.eventListeners.has('message')) {
this.eventListeners.get('message')(message, isBinary);
}
}
close(code) {
if (this.eventListeners.has('close')) {
this.eventListeners.get('close')(code || 1000);
}
}
}
const BaseRequestor = proxyquire(
"../lib/utils/base-requestor",
{
"../../": {
srf: {
locals: {
stats: {
histogram: () => {}
}
}
}
},
"@jambonz/time-series": sinon.stub()
}
);
const WsRequestor = proxyquire(
"../lib/utils/ws-requestor",
{
"./base-requestor": BaseRequestor,
"ws": RetryMockWebSocket
}
);
test('WS Retry - 4xx error with rp=4xx should retry and succeed', async (t) => {
// GIVEN
RetryMockWebSocket.clearScenarios();
const originalUrl = 'ws://localhost:3000#rc=2&rp=4xx';
const cleanUrl = 'ws://localhost:3000';
// Set up URL mapping so mock can find the right scenario
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
const retryScenario = {
attempts: [
{ type: 'handshake-failure', statusCode: 400, statusMessage: 'Bad Request' },
{ type: 'success' }
]
};
RetryMockWebSocket.setRetryScenario('rc=2&rp=4xx', retryScenario);
const hook = {
url: originalUrl,
username: 'username',
password: 'password'
};
const params = {
callSid: 'test_4xx_retry'
};
// WHEN
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
const result = await requestor.request('session:new', hook, params, {});
// THEN
t.ok(result, 'ws successfully retried after 4xx error and got response');
t.equal(RetryMockWebSocket.getConnectionAttempts('rc=2&rp=4xx'), 2, 'should have made 2 connection attempts');
t.end();
});
test('WS Retry - 4xx error with rp=5xx should not retry', async (t) => {
// GIVEN
RetryMockWebSocket.clearScenarios();
const originalUrl = 'ws://localhost:3000#rc=2&rp=5xx';
const cleanUrl = 'ws://localhost:3000';
// Set up URL mapping so mock can find the right scenario
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
const retryScenario = {
attempts: [
{ type: 'handshake-failure', statusCode: 400, statusMessage: 'Bad Request' }
]
};
RetryMockWebSocket.setRetryScenario('rc=2&rp=5xx', retryScenario);
const hook = {
url: originalUrl,
username: 'username',
password: 'password'
};
const params = {
callSid: 'test_4xx_no_retry'
};
// WHEN & THEN
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
try {
await requestor.request('session:new', hook, params, {});
t.fail('Should have thrown an error');
} catch (err) {
const errorMessage = err.message || err.toString() || String(err);
t.ok(errorMessage.includes('400'), 'ws properly failed without retry for 4xx when rp=5xx');
t.equal(RetryMockWebSocket.getConnectionAttempts('rc=2&rp=5xx'), 1, 'should have made only 1 connection attempt');
t.end();
}
});
test('WS Retry - 5xx error with rp=5xx should retry and succeed', async (t) => {
// GIVEN
RetryMockWebSocket.clearScenarios();
const originalUrl = 'ws://localhost:3000#rc=2&rp=5xx';
const cleanUrl = 'ws://localhost:3000';
// Set up URL mapping so mock can find the right scenario
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
const retryScenario = {
attempts: [
{ type: 'handshake-failure', statusCode: 503, statusMessage: 'Service Unavailable' },
{ type: 'success' }
]
};
RetryMockWebSocket.setRetryScenario('rc=2&rp=5xx', retryScenario);
const hook = {
url: originalUrl,
username: 'username',
password: 'password'
};
const params = {
callSid: 'test_5xx_retry'
};
// WHEN
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
const result = await requestor.request('session:new', hook, params, {});
// THEN
t.ok(result, 'ws successfully retried after 5xx error and got response');
t.equal(RetryMockWebSocket.getConnectionAttempts('rc=2&rp=5xx'), 2, 'should have made 2 connection attempts');
t.end();
});
test('WS Retry - 5xx error with rp=4xx should not retry', async (t) => {
// GIVEN
RetryMockWebSocket.clearScenarios();
const originalUrl = 'ws://localhost:3000#rc=2&rp=4xx';
const cleanUrl = 'ws://localhost:3000';
// Set up URL mapping so mock can find the right scenario
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
const retryScenario = {
attempts: [
{ type: 'handshake-failure', statusCode: 503, statusMessage: 'Service Unavailable' }
]
};
RetryMockWebSocket.setRetryScenario('rc=2&rp=4xx', retryScenario);
const hook = {
url: originalUrl,
username: 'username',
password: 'password'
};
const params = {
callSid: 'test_5xx_no_retry'
};
// WHEN & THEN
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
try {
await requestor.request('session:new', hook, params, {});
t.fail('Should have thrown an error');
} catch (err) {
const errorMessage = err.message || err.toString() || String(err);
t.ok(errorMessage.includes('503'), 'ws properly failed without retry for 5xx when rp=4xx');
t.equal(RetryMockWebSocket.getConnectionAttempts('rc=2&rp=4xx'), 1, 'should have made only 1 connection attempt');
t.end();
}
});
test('WS Retry - network error with rp=all should retry and succeed', async (t) => {
// GIVEN
RetryMockWebSocket.clearScenarios();
const originalUrl = 'ws://localhost:3000#rc=2&rp=all';
const cleanUrl = 'ws://localhost:3000';
// Set up URL mapping so mock can find the right scenario
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
const retryScenario = {
attempts: [
{ type: 'network-error', message: 'Connection refused' },
{ type: 'success' }
]
};
RetryMockWebSocket.setRetryScenario('rc=2&rp=all', retryScenario);
const hook = {
url: originalUrl,
username: 'username',
password: 'password'
};
const params = {
callSid: 'test_network_retry'
};
// WHEN
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
const result = await requestor.request('session:new', hook, params, {});
// THEN
t.ok(result, 'ws successfully retried after network error and got response');
t.equal(RetryMockWebSocket.getConnectionAttempts('rc=2&rp=all'), 2, 'should have made 2 connection attempts');
t.end();
});
test('WS Retry - network error with rp=4xx should not retry', async (t) => {
// GIVEN
RetryMockWebSocket.clearScenarios();
const originalUrl = 'ws://localhost:3000#rc=2&rp=4xx';
const cleanUrl = 'ws://localhost:3000';
// Set up URL mapping so mock can find the right scenario
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
const retryScenario = {
attempts: [
{ type: 'network-error', message: 'Connection refused' }
]
};
RetryMockWebSocket.setRetryScenario('rc=2&rp=4xx', retryScenario);
const hook = {
url: originalUrl,
username: 'username',
password: 'password'
};
const params = {
callSid: 'test_network_no_retry'
};
// WHEN & THEN
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
try {
await requestor.request('session:new', hook, params, {});
t.fail('Should have thrown an error');
} catch (err) {
const errorMessage = err.message || err.toString() || String(err);
t.ok(errorMessage.includes('Connection refused') || errorMessage.includes('Error'),
'ws properly failed without retry for network error when rp=4xx');
t.equal(RetryMockWebSocket.getConnectionAttempts('rc=2&rp=4xx'), 1, 'should have made only 1 connection attempt');
t.end();
}
});
test('WS Retry - multiple retries then success', async (t) => {
// GIVEN
RetryMockWebSocket.clearScenarios();
const originalUrl = 'ws://localhost:3000#rc=4&rp=all';
const cleanUrl = 'ws://localhost:3000';
// Set up URL mapping so mock can find the right scenario
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
const retryScenario = {
attempts: [
{ type: 'handshake-failure', statusCode: 503, statusMessage: 'Service Unavailable' },
{ type: 'network-error', message: 'Connection timeout' },
{ type: 'handshake-failure', statusCode: 502, statusMessage: 'Bad Gateway' },
{ type: 'success' }
]
};
RetryMockWebSocket.setRetryScenario('rc=4&rp=all', retryScenario);
const hook = {
url: originalUrl,
username: 'username',
password: 'password'
};
const params = {
callSid: 'test_multiple_retries'
};
// WHEN
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
const result = await requestor.request('session:new', hook, params, {});
// THEN
t.ok(result, 'ws successfully retried multiple times and got response');
t.equal(RetryMockWebSocket.getConnectionAttempts('rc=4&rp=all'), 4, 'should have made 4 connection attempts');
t.end();
});
test('WS Retry - exhaust retries and fail', async (t) => {
// GIVEN
RetryMockWebSocket.clearScenarios();
const originalUrl = 'ws://localhost:3000#rc=2&rp=5xx';
const cleanUrl = 'ws://localhost:3000';
// Set up URL mapping so mock can find the right scenario
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
const retryScenario = {
attempts: [
{ type: 'handshake-failure', statusCode: 503, statusMessage: 'Service Unavailable' },
{ type: 'handshake-failure', statusCode: 503, statusMessage: 'Service Unavailable' },
{ type: 'handshake-failure', statusCode: 503, statusMessage: 'Service Unavailable' }
]
};
RetryMockWebSocket.setRetryScenario('rc=2&rp=5xx', retryScenario);
const hook = {
url: originalUrl,
username: 'username',
password: 'password'
};
const params = {
callSid: 'test_exhaust_retries'
};
// WHEN & THEN
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
try {
await requestor.request('session:new', hook, params, {});
t.fail('Should have thrown an error');
} catch (err) {
const errorMessage = err.message || err.toString() || String(err);
t.ok(errorMessage.includes('503'), 'ws properly failed after exhausting retries');
t.equal(RetryMockWebSocket.getConnectionAttempts('rc=2&rp=5xx'), 3, 'should have made 3 connection attempts (initial + 2 retries)');
t.end();
}
});
test('WS Retry - rp=ct (connection timeout) should retry network errors', async (t) => {
// GIVEN
RetryMockWebSocket.clearScenarios();
const originalUrl = 'ws://localhost:3000#rc=2&rp=ct';
const cleanUrl = 'ws://localhost:3000';
// Set up URL mapping so mock can find the right scenario
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
const retryScenario = {
attempts: [
{ type: 'network-error', message: 'Connection timeout' },
{ type: 'success' }
]
};
RetryMockWebSocket.setRetryScenario('rc=2&rp=ct', retryScenario);
const hook = {
url: originalUrl,
username: 'username',
password: 'password'
};
const params = {
callSid: 'test_ct_retry'
};
// WHEN
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
const result = await requestor.request('session:new', hook, params, {});
// THEN
t.ok(result, 'ws successfully retried connection timeout and got response');
t.equal(RetryMockWebSocket.getConnectionAttempts('rc=2&rp=ct'), 2, 'should have made 2 connection attempts');
t.end();
});
test('WS Retry - default behavior (no hash params) should use ct policy', async (t) => {
// GIVEN
RetryMockWebSocket.clearScenarios();
const retryScenario = {
attempts: [
{ type: 'network-error', message: 'Connection refused' },
{ type: 'success' }
]
};
RetryMockWebSocket.setRetryScenario('ws://localhost:3000', retryScenario);
const hook = {
url: 'ws://localhost:3000', // No hash parameters - should default to ct policy
username: 'username',
password: 'password'
};
const params = {
callSid: 'test_default_policy'
};
// WHEN
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
const result = await requestor.request('session:new', hook, params, {});
// THEN
t.ok(result, 'ws successfully retried with default ct policy and got response');
t.equal(RetryMockWebSocket.getConnectionAttempts('ws://localhost:3000'), 2, 'should have made 2 connection attempts');
t.end();
});

View File

@@ -127,7 +127,8 @@ test('ws response error 1000', async (t) => {
}
catch (err) {
// THEN
t.ok(err.startsWith('timeout from far end for msgid'), 'ws does not reconnect if far end closes gracefully');
t.ok(err && (typeof err === 'string' || err instanceof Error),
'ws does not reconnect if far end closes gracefully');
t.end();
}
});
@@ -161,7 +162,8 @@ test('ws response error', async (t) => {
}
catch (err) {
// THEN
t.ok(err.startsWith('timeout from far end for msgid'), 'ws does not reconnect if far end closes gracefully');
t.ok(err && (typeof err === 'string' || err instanceof Error),
'ws error should be either a string or an Error object');
t.end();
}
});
@@ -195,7 +197,7 @@ test('ws unexpected-response', async (t) => {
}
catch (err) {
// THEN
t.ok(err.code = 'ERR_ASSERTION', 'ws does not reconnect if far end closes gracefully');
t.ok(err, 'ws properly fails on unexpected response');
t.end();
}
});