mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2026-01-25 02:07:56 +00:00
Compare commits
1034 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bea7e83e1 | ||
|
|
812076d4fe | ||
|
|
b0b74871e7 | ||
|
|
29708a1f7c | ||
|
|
e686a11808 | ||
|
|
25f58d2e43 | ||
|
|
8e9ab83ca4 | ||
|
|
e975511df5 | ||
|
|
4386df993c | ||
|
|
f20d3dba2f | ||
|
|
b734952855 | ||
|
|
4990b1fb68 | ||
|
|
3475f39b1d | ||
|
|
690a7fcd55 | ||
|
|
760394aa5e | ||
|
|
603dd482bc | ||
|
|
f670626cf7 | ||
|
|
b92a9c700e | ||
|
|
761b7f26e7 | ||
|
|
76df58bfc2 | ||
|
|
c1cb57c3f6 | ||
|
|
610c9af274 | ||
|
|
c0a35af591 | ||
|
|
9585018147 | ||
|
|
d7884a837a | ||
|
|
ca0bf36815 | ||
|
|
6b68d32e2c | ||
|
|
8217a76697 | ||
|
|
5c8237b382 | ||
|
|
4ff5c845de | ||
|
|
78ebd08490 | ||
|
|
8b18532f31 | ||
|
|
e4bb00b382 | ||
|
|
14295dcebc | ||
|
|
4d68c179ea | ||
|
|
6205959f53 | ||
|
|
ed92cb2632 | ||
|
|
3098e04ed6 | ||
|
|
7e2fe72b6c | ||
|
|
c2666b7a09 | ||
|
|
9d54ca8116 | ||
|
|
472f4f4532 | ||
|
|
63899d0091 | ||
|
|
31e6997746 | ||
|
|
15b583ef2c | ||
|
|
0bf2013934 | ||
|
|
182c310191 | ||
|
|
4e74bab728 | ||
|
|
87195b6444 | ||
|
|
eb5e6fa515 | ||
|
|
305facb03b | ||
|
|
d310ba0ed1 | ||
|
|
77f0fc85a3 | ||
|
|
c708b7d007 | ||
|
|
343b382373 | ||
|
|
0a541e089d | ||
|
|
d910981b1a | ||
|
|
3f2744f032 | ||
|
|
fcaf2e59e7 | ||
|
|
ee846b283d | ||
|
|
acdb8695a0 | ||
|
|
f33f197e8d | ||
|
|
9c437ab687 | ||
|
|
1873694784 | ||
|
|
d36e6b4c22 | ||
|
|
0470168757 | ||
|
|
3120dbc3e0 | ||
|
|
8b8283e603 | ||
|
|
29de4b8878 | ||
|
|
fa5fc1af9f | ||
|
|
a5e778d7f3 | ||
|
|
bf4ae5b618 | ||
|
|
ad2d99c417 | ||
|
|
af4e17f447 | ||
|
|
cd2563ce17 | ||
|
|
af475cbea4 | ||
|
|
69ba18acd1 | ||
|
|
8bed44cce3 | ||
|
|
8ede41714b | ||
|
|
ee54e4341a | ||
|
|
4bf2f42f33 | ||
|
|
e09c763d3a | ||
|
|
e8a7366526 | ||
|
|
122d267816 | ||
|
|
33bca8e67c | ||
|
|
9c05fd3deb | ||
|
|
7fa0041f6b | ||
|
|
59d9c62cbe | ||
|
|
55b408eecb | ||
|
|
f241faa871 | ||
|
|
65d35c893c | ||
|
|
dbdc1cd43d | ||
|
|
7105453d81 | ||
|
|
8487a4be68 | ||
|
|
2ddcd53d6b | ||
|
|
a4d07ddce0 | ||
|
|
16e044cabf | ||
|
|
ba282d775d | ||
|
|
a194ba833e | ||
|
|
77f3d9d7ec | ||
|
|
4dbc7df93d | ||
|
|
f71f0ac69a | ||
|
|
edb7e21ff9 | ||
|
|
cafd9530a2 | ||
|
|
ca8cace284 | ||
|
|
499c800213 | ||
|
|
97952afb1d | ||
|
|
f4e68d0ea1 | ||
|
|
6bad1a22f3 | ||
|
|
fcefa1ff31 | ||
|
|
67cd53c930 | ||
|
|
a59784b8ab | ||
|
|
a2581eaeb4 | ||
|
|
3706aa4d98 | ||
|
|
25f1e65f63 | ||
|
|
c9f0481ca6 | ||
|
|
564f6c9e55 | ||
|
|
02f25f8343 | ||
|
|
13ef89d605 | ||
|
|
d05e470867 | ||
|
|
17250f8386 | ||
|
|
ba3f46df64 | ||
|
|
f37e1540ee | ||
|
|
5e04db82bf | ||
|
|
0aa37a83ae | ||
|
|
c29ab0d858 | ||
|
|
71d4c90cbc | ||
|
|
a929a649f9 | ||
|
|
3bb4f1a29f | ||
|
|
54cc76606b | ||
|
|
0458bb7d6c | ||
|
|
dce4fe1f82 | ||
|
|
e96c35d571 | ||
|
|
070671a3fb | ||
|
|
efdb56f0a0 | ||
|
|
e2edbb4a5b | ||
|
|
3a6d63a1c6 | ||
|
|
c874ab8100 | ||
|
|
24a66fed64 | ||
|
|
c8c3738ae8 | ||
|
|
c1330d4651 | ||
|
|
27f3a4b520 | ||
|
|
594c867192 | ||
|
|
71c475e758 | ||
|
|
22ef201360 | ||
|
|
5be3a910ad | ||
|
|
7615509e0b | ||
|
|
851c071345 | ||
|
|
7911459c8c | ||
|
|
be258950b0 | ||
|
|
0520386a1e | ||
|
|
a4b1b22324 | ||
|
|
e800cca961 | ||
|
|
1efb198f72 | ||
|
|
4b5df855e1 | ||
|
|
24126ef1ec | ||
|
|
8e4995ec02 | ||
|
|
a005253a9f | ||
|
|
10efc5d608 | ||
|
|
1c48c40496 | ||
|
|
c79a6aaf8a | ||
|
|
da5f51e8e0 | ||
|
|
e7fd40e297 | ||
|
|
f541ff1a15 | ||
|
|
98b968d61f | ||
|
|
f09722a5b5 | ||
|
|
f84b3793e1 | ||
|
|
84b7456c2d | ||
|
|
c67499e38b | ||
|
|
e372a3cdfb | ||
|
|
ea303caa1c | ||
|
|
2af67d8f05 | ||
|
|
96b3b0fe07 | ||
|
|
b898b70520 | ||
|
|
b9ef00dfc7 | ||
|
|
68fa3c013d | ||
|
|
7c24208067 | ||
|
|
7f7c26e982 | ||
|
|
402adc2098 | ||
|
|
724d4fb713 | ||
|
|
673827cce3 | ||
|
|
c4c5ad33d8 | ||
|
|
7bbfc01cb0 | ||
|
|
7daf056d6b | ||
|
|
e69afc4be4 | ||
|
|
3a7cc27d0a | ||
|
|
c4a6057fc6 | ||
|
|
174438bb01 | ||
|
|
4348615b75 | ||
|
|
d365883bfe | ||
|
|
c0ab936b76 | ||
|
|
600ff763fa | ||
|
|
4d077e990f | ||
|
|
eccef54b04 | ||
|
|
2790e6d9ad | ||
|
|
f95d8639be | ||
|
|
fc838512b6 | ||
|
|
68992bccf6 | ||
|
|
c131fceea7 | ||
|
|
12174359f2 | ||
|
|
020c84d2df | ||
|
|
62d71d2504 | ||
|
|
c594797cb0 | ||
|
|
bae96a6752 | ||
|
|
ee68575ea4 | ||
|
|
6d0aeff6e2 | ||
|
|
d2a5d483d0 | ||
|
|
d3eb106d5d | ||
|
|
689e55bdf0 | ||
|
|
ed7e036890 | ||
|
|
f90fcdf57b | ||
|
|
c2a1819cbb | ||
|
|
4259a24fa0 | ||
|
|
e4e37d5697 | ||
|
|
b7a3c2970a | ||
|
|
cc33ac1d51 | ||
|
|
4b4807e4cf | ||
|
|
9a3c731389 | ||
|
|
edd8f20642 | ||
|
|
ee24041cba | ||
|
|
83f7abcd89 | ||
|
|
c9194168d2 | ||
|
|
83191487cf | ||
|
|
65ef4e6d64 | ||
|
|
ddb4719220 | ||
|
|
f514a65f63 | ||
|
|
5ccea65b7f | ||
|
|
8672152873 | ||
|
|
425b88f930 | ||
|
|
111976bea5 | ||
|
|
ec6d7b3f42 | ||
|
|
5e1b826da4 | ||
|
|
be9c3406c1 | ||
|
|
2f3ef1654a | ||
|
|
0baa080a1e | ||
|
|
f5cbd26c9f | ||
|
|
d9fd82fa60 | ||
|
|
76a3aa7f42 | ||
|
|
cafe149bdf | ||
|
|
9969e39e7e | ||
|
|
8eea212df2 | ||
|
|
e8e356ea3a | ||
|
|
c5e19bf775 | ||
|
|
498dd64025 | ||
|
|
24b6d2464b | ||
|
|
cd5421120f | ||
|
|
d7c3a4a632 | ||
|
|
c53ad89154 | ||
|
|
10b98630d3 | ||
|
|
d132bdb92b | ||
|
|
6be3fd9b64 | ||
|
|
844b0cb05d | ||
|
|
c0b56d4fc6 | ||
|
|
d27de284e7 | ||
|
|
5e97847a2f | ||
|
|
17c379df47 | ||
|
|
e7bc0b0737 | ||
|
|
dfe623e78a | ||
|
|
56b8f0623b | ||
|
|
7bcbab5b74 | ||
|
|
44e6a3513d | ||
|
|
fad16144b9 | ||
|
|
6523a861c0 | ||
|
|
cff67f5e4c | ||
|
|
c77bd84e0e | ||
|
|
3cd7a619ad | ||
|
|
59cf02bd04 | ||
|
|
a18d55e9ab | ||
|
|
d474b9d604 | ||
|
|
8d2b60c284 | ||
|
|
9cf9d4f587 | ||
|
|
bd002ede48 | ||
|
|
1a2aa91973 | ||
|
|
e322b7d8d3 | ||
|
|
7da11df88e | ||
|
|
09cf1345f6 | ||
|
|
2595f527ff | ||
|
|
1d77c0cd20 | ||
|
|
9eab81268b | ||
|
|
ecf3d140d6 | ||
|
|
4a52be9171 | ||
|
|
9b722ae36d | ||
|
|
370b046fac | ||
|
|
fca391c32e | ||
|
|
043860c4a3 | ||
|
|
a021ee3112 | ||
|
|
8999c85a71 | ||
|
|
72147a8110 | ||
|
|
93d0e41e31 | ||
|
|
5b1d8a8ff3 | ||
|
|
ec58232b61 | ||
|
|
65c241bcd1 | ||
|
|
75b6f89e0c | ||
|
|
b80d39d205 | ||
|
|
40f70e3531 | ||
|
|
1914b88af9 | ||
|
|
c946a5d14d | ||
|
|
878578fe0f | ||
|
|
9b3be6c0b9 | ||
|
|
4ae661daea | ||
|
|
dbd3b59901 | ||
|
|
06b066a3f2 | ||
|
|
fc3655c9bd | ||
|
|
1b5f801830 | ||
|
|
d0ebe3f99f | ||
|
|
51a379998f | ||
|
|
c2ae42a456 | ||
|
|
c187685054 | ||
|
|
81234a583c | ||
|
|
206849fa25 | ||
|
|
662b6d3d95 | ||
|
|
5c070597cf | ||
|
|
42be9ff1ca | ||
|
|
f0533c881b | ||
|
|
c894369a13 | ||
|
|
565478cc0a | ||
|
|
cdd25ca33d | ||
|
|
ef2306e558 | ||
|
|
9c33a790bd | ||
|
|
9f9a9ec598 | ||
|
|
75566bb268 | ||
|
|
a55f81676b | ||
|
|
48a81072e8 | ||
|
|
74ede31cd3 | ||
|
|
048229f019 | ||
|
|
71e266ae32 | ||
|
|
5b607693dc | ||
|
|
0491c5ce25 | ||
|
|
a7fa2f95dd | ||
|
|
901e412343 | ||
|
|
e57c7ba90a | ||
|
|
b867395d87 | ||
|
|
1a80910f91 | ||
|
|
5d4f25622d | ||
|
|
aabf37e269 | ||
|
|
b45275789b | ||
|
|
6d5ef6a215 | ||
|
|
b423a51638 | ||
|
|
b4ff2ea702 | ||
|
|
f22d66dfd6 | ||
|
|
09a83e3a31 | ||
|
|
d3d494191f | ||
|
|
859e816a8e | ||
|
|
29bbcf1be0 | ||
|
|
6f6d7a06b0 | ||
|
|
a2ba80a9a3 | ||
|
|
9d70ed96a1 | ||
|
|
8173a306f7 | ||
|
|
2e69630544 | ||
|
|
15829139c1 | ||
|
|
2c48083c26 | ||
|
|
9d8291f892 | ||
|
|
3e8474867f | ||
|
|
9eb315ecd6 | ||
|
|
2ec1460b4e | ||
|
|
e30782ea7b | ||
|
|
83c1c07eb0 | ||
|
|
47fbc1a4a4 | ||
|
|
7474a359a4 | ||
|
|
30977b309c | ||
|
|
bcb4bf43bf | ||
|
|
077460d0e2 | ||
|
|
6629b45671 | ||
|
|
353a9c1917 | ||
|
|
230fe9ea11 | ||
|
|
bb81f9f3da | ||
|
|
a7673c1819 | ||
|
|
59248c7638 | ||
|
|
46755f909c | ||
|
|
4273196447 | ||
|
|
e5b60ca9b0 | ||
|
|
86a14daf79 | ||
|
|
c66ad39001 | ||
|
|
0a0cbd57ba | ||
|
|
eb2d90ffaa | ||
|
|
454ff7d1b8 | ||
|
|
7e349fe4e5 | ||
|
|
9478f3a1b8 | ||
|
|
a3c241b569 | ||
|
|
5a68563f96 | ||
|
|
1cdd0cf611 | ||
|
|
9ae4b04fc5 | ||
|
|
170c3c7ec4 | ||
|
|
7c36a08852 | ||
|
|
633237da1b | ||
|
|
708c2c661f | ||
|
|
87632c549e | ||
|
|
31559cbb3b | ||
|
|
1156bae2de | ||
|
|
c6c599ab99 | ||
|
|
4d0f0fe75f | ||
|
|
6d625d87ad | ||
|
|
7fee2ba2dc | ||
|
|
4b3234f4e4 | ||
|
|
6b9f6a7d90 | ||
|
|
3cdf568fb6 | ||
|
|
e73bef4af0 | ||
|
|
42d1069617 | ||
|
|
e5772d6b85 | ||
|
|
f43a5c1491 | ||
|
|
67f8f7181a | ||
|
|
ddab4d7548 | ||
|
|
916d988dbd | ||
|
|
d6b74c3da8 | ||
|
|
3171b138f9 | ||
|
|
168b4dc051 | ||
|
|
cf0f4d405f | ||
|
|
24fccbdae5 | ||
|
|
7992bc6ca0 | ||
|
|
4b7b0e309b | ||
|
|
1ff4f01d64 | ||
|
|
4a5dbb0115 | ||
|
|
0a2808e64e | ||
|
|
320baf4ac8 | ||
|
|
a92ea9c5da | ||
|
|
4ffa9f915b | ||
|
|
2285ec5329 | ||
|
|
09ae083c9a | ||
|
|
6a3e12e293 | ||
|
|
48f2c57ae2 | ||
|
|
f651cfa0b7 | ||
|
|
cb78627e66 | ||
|
|
ae9386791f | ||
|
|
1aa0b07b8f | ||
|
|
4e916acf6c | ||
|
|
991fff3386 | ||
|
|
76cf4e527f | ||
|
|
d7affddd85 | ||
|
|
d42798e0b4 | ||
|
|
6a8a2aa955 | ||
|
|
6587b1f758 | ||
|
|
c29def92e8 | ||
|
|
a1793ac359 | ||
|
|
d220733dea | ||
|
|
a09605fc51 | ||
|
|
7f59bba634 | ||
|
|
1477605e66 | ||
|
|
4f0ab83f5f | ||
|
|
2935574440 | ||
|
|
c10c561ba1 | ||
|
|
2ccd33e212 | ||
|
|
a03baa8461 | ||
|
|
90df33a15c | ||
|
|
a15479e6dc | ||
|
|
dd74cb2cc6 | ||
|
|
7a02c36bad | ||
|
|
78fd4549af | ||
|
|
b1ecf069bf | ||
|
|
6f0dbef433 | ||
|
|
32dcb2adfa | ||
|
|
ee514f7459 | ||
|
|
4cfea0707a | ||
|
|
f8c5abe9e9 | ||
|
|
ad722a55ee | ||
|
|
82939214a2 | ||
|
|
043a171f41 | ||
|
|
c8e9b34b53 | ||
|
|
d7dcdb1d0c | ||
|
|
fbd0782258 | ||
|
|
38f9329b12 | ||
|
|
d4bfdf0916 | ||
|
|
9203deef0f | ||
|
|
48b182c891 | ||
|
|
e8e987cb9d | ||
|
|
38ea9e7411 | ||
|
|
7b11a56a53 | ||
|
|
66305b5aea | ||
|
|
6793bbf330 | ||
|
|
d8543f73f2 | ||
|
|
e1dad569dc | ||
|
|
643bee48c5 | ||
|
|
487bfd90d9 | ||
|
|
810f6eb695 | ||
|
|
62bc6b4bac | ||
|
|
91fe3ceb06 | ||
|
|
a7d07ce7ae | ||
|
|
7cd6c27f90 | ||
|
|
aad24744f3 | ||
|
|
ab0452879e | ||
|
|
ffdb7a0bb5 | ||
|
|
354818b974 | ||
|
|
30beb9c093 | ||
|
|
b978b3bc2f | ||
|
|
a1c38f8a2e | ||
|
|
37f3668016 | ||
|
|
55935e3f35 | ||
|
|
b7070121ee | ||
|
|
01260ad054 | ||
|
|
bd911c88f9 | ||
|
|
d96712a8d6 | ||
|
|
fdd8f7e743 | ||
|
|
bb852600c0 | ||
|
|
210bbcbdbf | ||
|
|
5910dbf0d3 | ||
|
|
90468ffe48 | ||
|
|
863c4dfa34 | ||
|
|
484be8442c | ||
|
|
7393e3bcb7 | ||
|
|
32a84b7b19 | ||
|
|
6933e82d46 | ||
|
|
fb1801ce11 | ||
|
|
09abb23968 | ||
|
|
eb1e0d3bf5 | ||
|
|
3b6c103618 | ||
|
|
feccc0fca7 | ||
|
|
51bcb5a2d2 | ||
|
|
7a184a8bbc | ||
|
|
5043edfd4e | ||
|
|
9948592080 | ||
|
|
6dc019e836 | ||
|
|
a22bc8ea42 | ||
|
|
0356b996ba | ||
|
|
271587617e | ||
|
|
0b29e67a0c | ||
|
|
e656d275fe | ||
|
|
fabf01f8b5 | ||
|
|
85ab75d8e3 | ||
|
|
5c2630fe1f | ||
|
|
9942313ea1 | ||
|
|
e67cb18b6d | ||
|
|
86df53f8c4 | ||
|
|
5d50f68725 | ||
|
|
f22b236dfc | ||
|
|
2862c827e0 | ||
|
|
266980d770 | ||
|
|
04003a709e | ||
|
|
565ee609ef | ||
|
|
9587465e85 | ||
|
|
845d80a23d | ||
|
|
3109db7861 | ||
|
|
11c5047465 | ||
|
|
e19ea629f0 | ||
|
|
fe529c6bfb | ||
|
|
e980b82ec4 | ||
|
|
318ca19791 | ||
|
|
e2bd211346 | ||
|
|
410c07fae6 | ||
|
|
2ebfbfb3d8 | ||
|
|
a29795839d | ||
|
|
28088a4cdd | ||
|
|
afb381eec9 | ||
|
|
ed00ccb681 | ||
|
|
6e945dde9a | ||
|
|
efdea3e514 | ||
|
|
5131d524ce | ||
|
|
c0114015ea | ||
|
|
a293ec09d0 | ||
|
|
f71ae83ce4 | ||
|
|
0dd161913c | ||
|
|
63ab554908 | ||
|
|
e1bd075ebc | ||
|
|
9de89258a1 | ||
|
|
145ed488db | ||
|
|
c06a43adfa | ||
|
|
bebc82d194 | ||
|
|
cdc82e99ff | ||
|
|
dd4d9aa261 | ||
|
|
1dcf9ee5a2 | ||
|
|
4b28db0946 | ||
|
|
e7ff76b938 | ||
|
|
f245275983 | ||
|
|
690deed89d | ||
|
|
26053ec709 | ||
|
|
34e8203338 | ||
|
|
7be3c64116 | ||
|
|
f71d3aed8b | ||
|
|
5ab24337b2 | ||
|
|
2af76d94a6 | ||
|
|
4919c05181 | ||
|
|
3084a9d6ba | ||
|
|
1c683f1142 | ||
|
|
ab1947e23e | ||
|
|
5527abff09 | ||
|
|
68827112fc | ||
|
|
8a9a2df128 | ||
|
|
3a3544a5e8 | ||
|
|
cbeb706946 | ||
|
|
f005262615 | ||
|
|
67ec28484c | ||
|
|
803a944240 | ||
|
|
a5cd342e46 | ||
|
|
e91feb64f5 | ||
|
|
ae688ddc7e | ||
|
|
9b21b65478 | ||
|
|
c09425fa89 | ||
|
|
6706992b4b | ||
|
|
0fdcb3a6d6 | ||
|
|
50057deca9 | ||
|
|
c7eacdd0f8 | ||
|
|
e990b5dbf9 | ||
|
|
7ae37b1e60 | ||
|
|
ed284c367d | ||
|
|
272380dc62 | ||
|
|
61dbb659b3 | ||
|
|
fbae7c0eab | ||
|
|
4894a85569 | ||
|
|
0e5bb876ce | ||
|
|
8658d03f1f | ||
|
|
f2ff5250b0 | ||
|
|
c37fba541f | ||
|
|
f9921cf4e9 | ||
|
|
86fed4ec90 | ||
|
|
9d07a1354c | ||
|
|
2775c7ddd1 | ||
|
|
70822cb278 | ||
|
|
14a02735be | ||
|
|
4b3ebe37ac | ||
|
|
f4fbd07f8e | ||
|
|
6ebba8673f | ||
|
|
2b06177dc5 | ||
|
|
088316d266 | ||
|
|
8c0044a378 | ||
|
|
dae307d71f | ||
|
|
1b5b37184b | ||
|
|
2f8efb80d0 | ||
|
|
c57e88b496 | ||
|
|
7122d955fe | ||
|
|
028aeea856 | ||
|
|
567b03fd36 | ||
|
|
d5c04d2133 | ||
|
|
a2e909b057 | ||
|
|
c3627cecb8 | ||
|
|
6753fdc2b4 | ||
|
|
740d996739 | ||
|
|
714d06a600 | ||
|
|
0c52324915 | ||
|
|
2e3fb60e72 | ||
|
|
05a4665f87 | ||
|
|
b16d49d8ea | ||
|
|
aad2d52efd | ||
|
|
83d767116b | ||
|
|
b4673ad942 | ||
|
|
9b8bb07a97 | ||
|
|
29f578ff5c | ||
|
|
6d86793494 | ||
|
|
9f95fde67e | ||
|
|
010b4d2778 | ||
|
|
8d81c20c1a | ||
|
|
69f796e960 | ||
|
|
4db03d3d1b | ||
|
|
a60c6a4740 | ||
|
|
5b875c3ad4 | ||
|
|
bf19d2ae6d | ||
|
|
37efdc62be | ||
|
|
78a76bb1f4 | ||
|
|
39fb762a15 | ||
|
|
2cc3140de0 | ||
|
|
1a1f2770b6 | ||
|
|
23f3b44b8b | ||
|
|
753d46e513 | ||
|
|
71a2435c63 | ||
|
|
8686348454 | ||
|
|
f511e6ab6b | ||
|
|
706cd4b94b | ||
|
|
e5c209e269 | ||
|
|
d903dbe28d | ||
|
|
d88321c24d | ||
|
|
6e1761bab6 | ||
|
|
509bb065bb | ||
|
|
203b9774ca | ||
|
|
fade47d423 | ||
|
|
26e52d131e | ||
|
|
70caf00dd1 | ||
|
|
f044cdd150 | ||
|
|
c3d39f0970 | ||
|
|
9c69a2c79f | ||
|
|
e0607b9c2e | ||
|
|
dc378cd065 | ||
|
|
138950c534 | ||
|
|
215a28b615 | ||
|
|
3a5efa37b9 | ||
|
|
917b8f332c | ||
|
|
17848ea22c | ||
|
|
43af27e802 | ||
|
|
b25f92e17a | ||
|
|
90cb5e1348 | ||
|
|
cf821569b3 | ||
|
|
218f2d6c67 | ||
|
|
c2c8f00978 | ||
|
|
32714d73f3 | ||
|
|
8da85ebd5a | ||
|
|
dcedf68264 | ||
|
|
05c5d2211f | ||
|
|
0c089e2380 | ||
|
|
099f33857c | ||
|
|
bd49dacac4 | ||
|
|
876824abde | ||
|
|
468a9e6d6b | ||
|
|
c88163fe11 | ||
|
|
bf7ece8f17 | ||
|
|
e90ef6bc70 | ||
|
|
a59f6097d7 | ||
|
|
887c6243e2 | ||
|
|
127432f2ec | ||
|
|
4f0439dad9 | ||
|
|
9c188736f9 | ||
|
|
a69dbb3d4f | ||
|
|
b2e21f06a8 | ||
|
|
a325bb554a | ||
|
|
aa5e3d9437 | ||
|
|
6346954e7a | ||
|
|
5b6f7dd3ee | ||
|
|
7199db5edb | ||
|
|
8644b858b3 | ||
|
|
3d475217ca | ||
|
|
f580bc60f5 | ||
|
|
1a4f8563f2 | ||
|
|
a021ca19a5 | ||
|
|
2a4f8e3ff9 | ||
|
|
3298918322 | ||
|
|
f068aa5390 | ||
|
|
6e6ab56163 | ||
|
|
91204955c9 | ||
|
|
bc3552dda7 | ||
|
|
d459be2942 | ||
|
|
1c5c76de61 | ||
|
|
cb6817449d | ||
|
|
ffa006225b | ||
|
|
11d9a13ac7 | ||
|
|
21d5af367b | ||
|
|
2882fa2d0a | ||
|
|
a035b67e6c | ||
|
|
6979affb86 | ||
|
|
bb9c3a8df0 | ||
|
|
92fa3c249c | ||
|
|
7f808c6107 | ||
|
|
f95524863d | ||
|
|
aceaa5b7da | ||
|
|
7d57c85153 | ||
|
|
9aa0df256d | ||
|
|
627c38899f | ||
|
|
bdb40b3aa0 | ||
|
|
12ad7e556f | ||
|
|
05d6c8d467 | ||
|
|
5e9407ff4e | ||
|
|
e4fefe8f44 | ||
|
|
f7aac33af4 | ||
|
|
dc1d8de396 | ||
|
|
5be5b6d05d | ||
|
|
f51211b407 | ||
|
|
7f0e373e5f | ||
|
|
c3e5ffa52d | ||
|
|
0ee13fb794 | ||
|
|
4e84098036 | ||
|
|
6d34850dc6 | ||
|
|
76ff1835a6 | ||
|
|
a4e358596e | ||
|
|
c412554c6b | ||
|
|
34fe22f6e1 | ||
|
|
182ad8c716 | ||
|
|
036accab44 | ||
|
|
b37881a059 | ||
|
|
258e4b5434 | ||
|
|
aa4d72c80a | ||
|
|
5c38ace5ba | ||
|
|
dea58c2605 | ||
|
|
eb0f55e0e3 | ||
|
|
944b8a29ca | ||
|
|
daa02ac55a | ||
|
|
5134d5dbc6 | ||
|
|
a755e25568 | ||
|
|
13549286db | ||
|
|
72aaf80335 | ||
|
|
af33089a8a | ||
|
|
85d86cfdc3 | ||
|
|
de9f2ce5ca | ||
|
|
36c97e9562 | ||
|
|
13ea559cb1 | ||
|
|
698d12a95f | ||
|
|
359cb82d80 | ||
|
|
29dec24095 | ||
|
|
6330b0d443 | ||
|
|
24a0bc547f | ||
|
|
db5486de27 | ||
|
|
41d6c74c8e | ||
|
|
92ca40c9b3 | ||
|
|
3fa913215f | ||
|
|
0b132411c1 | ||
|
|
077d34dc9e | ||
|
|
49a75a3e3a | ||
|
|
6f214a66e8 | ||
|
|
3456c51118 | ||
|
|
13c38a9875 | ||
|
|
4f87cf9b38 | ||
|
|
bf21a1f9a4 | ||
|
|
81f6163aca | ||
|
|
547ca0281f | ||
|
|
3281a213c8 | ||
|
|
4f2fc70383 | ||
|
|
f72e8e654c | ||
|
|
cf2100f925 | ||
|
|
5a584f50da | ||
|
|
befe910503 | ||
|
|
040ec0db9b | ||
|
|
8459376f88 | ||
|
|
775a317821 | ||
|
|
9004f654ff | ||
|
|
6163657845 | ||
|
|
398daa87d5 | ||
|
|
4f5ab7d146 | ||
|
|
70f7775893 | ||
|
|
a950f9f738 | ||
|
|
ff8d7f3648 | ||
|
|
6e4ae69cb7 | ||
|
|
23eae34888 | ||
|
|
aaf94006db | ||
|
|
86b030db93 | ||
|
|
6abfdafe05 | ||
|
|
f1f83598ca | ||
|
|
3dd703411c | ||
|
|
8c5cdd374b | ||
|
|
15d784a4b0 | ||
|
|
7188648d3b | ||
|
|
d00ea5c95f | ||
|
|
ddcbda988f | ||
|
|
ddf00c0ddf | ||
|
|
fd8df533ab | ||
|
|
4b1199242f | ||
|
|
72225791b9 | ||
|
|
172dc1aaa7 | ||
|
|
72b74de767 | ||
|
|
9908485eb8 | ||
|
|
fb25389cd1 | ||
|
|
f317fbaa45 | ||
|
|
3c5d392407 | ||
|
|
5bfc451c85 | ||
|
|
47478fd409 | ||
|
|
c16a2662f2 | ||
|
|
c1130adf03 | ||
|
|
f982f6c7d8 | ||
|
|
f20190b0fc | ||
|
|
74e85e1b16 | ||
|
|
63e9cb985e | ||
|
|
2e88ab1f55 | ||
|
|
7f75a35515 | ||
|
|
941727e93f | ||
|
|
d8bfa33a00 | ||
|
|
30ed5b6a02 | ||
|
|
bac1b7f2c6 | ||
|
|
48deb3ae89 | ||
|
|
de83f735ea | ||
|
|
cfe9397502 | ||
|
|
dda3335060 | ||
|
|
2329f0cda0 | ||
|
|
36683dc151 | ||
|
|
ce738a7852 | ||
|
|
77a696a0dc | ||
|
|
62ff44540d | ||
|
|
e5821cddf8 | ||
|
|
25567a7842 | ||
|
|
40bd3c9c88 | ||
|
|
27d6d32359 | ||
|
|
142f5d409f | ||
|
|
da4a7184a4 | ||
|
|
2c72bf50cd | ||
|
|
b27f349fc6 | ||
|
|
138aa5836a | ||
|
|
e1a023c21e | ||
|
|
8acb4d1a24 | ||
|
|
26d4bfb63b | ||
|
|
45dcab8517 | ||
|
|
27e3cba00b | ||
|
|
097f36cb00 | ||
|
|
752eed428f | ||
|
|
afb874aabc | ||
|
|
59227febf9 | ||
|
|
8593f12b51 | ||
|
|
3bf1984854 | ||
|
|
0e45e9b27c | ||
|
|
b0a8a6828d | ||
|
|
27d4ad5674 | ||
|
|
d38e77c06c | ||
|
|
c9e2a162c2 | ||
|
|
2b9cb5105f | ||
|
|
afbbed3f5c | ||
|
|
f642967f02 | ||
|
|
fbe2aa2c06 | ||
|
|
5321b5c651 | ||
|
|
83c114803f | ||
|
|
0663174f46 | ||
|
|
3d4359fbe4 | ||
|
|
10382573fa | ||
|
|
c190279927 | ||
|
|
114f65b36a | ||
|
|
3e49616191 | ||
|
|
1e93973419 | ||
|
|
fe1778e9ae | ||
|
|
af15449451 | ||
|
|
12c34de15c | ||
|
|
7c77bedd15 | ||
|
|
0c5150cb30 | ||
|
|
2262973f43 | ||
|
|
db78ffffed | ||
|
|
2930cd6aaf | ||
|
|
2a013377cc | ||
|
|
dcf27ba5d3 | ||
|
|
f11feb7975 | ||
|
|
19dda9398d | ||
|
|
81edf1a6d6 | ||
|
|
72345f83c1 | ||
|
|
bedf25c6a2 | ||
|
|
a9e789f466 | ||
|
|
a779ead79f | ||
|
|
a3d3878218 | ||
|
|
4bc3e03605 | ||
|
|
62106a751f | ||
|
|
4c61ae5fbd | ||
|
|
708c13d5f6 | ||
|
|
7cf342eeb8 | ||
|
|
aebcf2b006 | ||
|
|
f0bd681ccc | ||
|
|
ac263de729 | ||
|
|
862405c232 | ||
|
|
3cd4c399d4 | ||
|
|
0d6cb8a2b3 | ||
|
|
05c5319cbc | ||
|
|
d15fdcf663 | ||
|
|
19f3cbaa43 | ||
|
|
ac8827c885 | ||
|
|
d1d082ceaf | ||
|
|
28415dc750 | ||
|
|
3d0c7fea52 | ||
|
|
3fed15b3b9 | ||
|
|
7c629e6faf | ||
|
|
649b3d5715 | ||
|
|
48fbbd48ad | ||
|
|
dacd3691ed | ||
|
|
df8dac367c | ||
|
|
1a2aaf9845 | ||
|
|
02f5efba48 | ||
|
|
99a6ffe56b | ||
|
|
ba32f1ea05 | ||
|
|
7de016589b | ||
|
|
9b59d08dcf | ||
|
|
473a34ec9f | ||
|
|
686cf1b094 | ||
|
|
5cc4852bf9 | ||
|
|
576f645489 | ||
|
|
8eb0cd1520 | ||
|
|
e441c5be36 | ||
|
|
dd48b5c9da | ||
|
|
c6168ce994 | ||
|
|
70e4e10a70 | ||
|
|
82768a0442 | ||
|
|
8b3ffe911d | ||
|
|
a7e0fb2e8a | ||
|
|
f8e84b5ad0 | ||
|
|
0cff553310 | ||
|
|
873729edb1 | ||
|
|
756db59671 | ||
|
|
59d685319e | ||
|
|
ec7a1858d6 | ||
|
|
63a00063c1 | ||
|
|
2a8f165468 | ||
|
|
d3f8e032d1 | ||
|
|
a1054d2d38 | ||
|
|
fa87a477ac | ||
|
|
69349dab75 | ||
|
|
b679d11fd7 | ||
|
|
ea8609b8c3 | ||
|
|
ef17ed40f7 | ||
|
|
5c5c9d9ae2 | ||
|
|
6e32d82364 | ||
|
|
bfd8355432 | ||
|
|
1a29d48334 | ||
|
|
4d6ef8e334 | ||
|
|
cac259ec1c | ||
|
|
1bc583e805 | ||
|
|
16c728e246 | ||
|
|
25c3512e41 | ||
|
|
5291824501 | ||
|
|
5f908492d7 | ||
|
|
1f32170788 | ||
|
|
bd9c7b741d | ||
|
|
b47e490424 | ||
|
|
6b63009707 | ||
|
|
91f507bf3f | ||
|
|
9d3c9accb9 | ||
|
|
95e4c22969 | ||
|
|
c02aa94500 | ||
|
|
950f1c83b7 | ||
|
|
e642e13946 | ||
|
|
8f65b0de2f | ||
|
|
e1528da8b1 | ||
|
|
7abc7866dd | ||
|
|
868427216f | ||
|
|
2c8c161954 | ||
|
|
884e63e0ef | ||
|
|
3624b05eb6 | ||
|
|
b739737c29 | ||
|
|
15517828d2 | ||
|
|
490472ca68 | ||
|
|
565ad2948c | ||
|
|
31bed8afbd | ||
|
|
6e78b46674 | ||
|
|
a4bcfca9e6 | ||
|
|
c1112ea477 | ||
|
|
4e4ce0914e | ||
|
|
1dc4728574 | ||
|
|
d7eeb52a84 | ||
|
|
f3fcdfc481 | ||
|
|
cd58d4a4f0 | ||
|
|
218152b844 | ||
|
|
06aafaa0e8 | ||
|
|
c663cbd7b2 | ||
|
|
1d9658905f | ||
|
|
a0508a2494 | ||
|
|
419c5ea9fd | ||
|
|
a806a4eb46 | ||
|
|
8ee590172b | ||
|
|
d31c53d383 | ||
|
|
86cc137085 | ||
|
|
6ba3f4474c | ||
|
|
fb62daf54d | ||
|
|
55a526e1d4 | ||
|
|
3909ca18a8 | ||
|
|
fb84dd364c | ||
|
|
bec31f1895 | ||
|
|
f54513f166 | ||
|
|
018ef8ddd3 | ||
|
|
d52de918e4 | ||
|
|
a93c348b1b | ||
|
|
56f7a0755c | ||
|
|
76bd88518a | ||
|
|
b66c6627ed | ||
|
|
a4286fefea | ||
|
|
43c8890faf | ||
|
|
88e65c8836 | ||
|
|
afc1fd3639 | ||
|
|
f7a76733a1 | ||
|
|
e4c3de0b5c | ||
|
|
673eebcb2f | ||
|
|
f3926d2c9c | ||
|
|
480817264d |
@@ -8,7 +8,7 @@
|
||||
"jsx": false,
|
||||
"modules": false
|
||||
},
|
||||
"ecmaVersion": 2017
|
||||
"ecmaVersion": 2020
|
||||
},
|
||||
"plugins": ["promise"],
|
||||
"rules": {
|
||||
|
||||
28
.github/workflows/build.yml
vendored
Normal file
28
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: CI
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- run: npm ci
|
||||
- run: npm run jslint
|
||||
- name: Install Docker Compose
|
||||
run: |
|
||||
sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
docker-compose --version
|
||||
- run: docker pull drachtio/sipp
|
||||
- run: npm test
|
||||
env:
|
||||
GCP_JSON_KEY: ${{ secrets.GCP_JSON_KEY }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||
MICROSOFT_REGION: ${{ secrets.MICROSOFT_REGION }}
|
||||
MICROSOFT_API_KEY: ${{ secrets.MICROSOFT_API_KEY }}
|
||||
54
.github/workflows/docker-publish.yml
vendored
Normal file
54
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
push:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: prepare tag
|
||||
id: prepare_tag
|
||||
run: |
|
||||
IMAGE_ID=jambonz/feature-server
|
||||
|
||||
# Strip git ref prefix from version
|
||||
VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
|
||||
|
||||
# Strip "v" prefix from tag name
|
||||
[[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')
|
||||
|
||||
# Use Docker `latest` tag convention
|
||||
[ "$VERSION" == "main" ] && VERSION=latest
|
||||
|
||||
echo IMAGE_ID=$IMAGE_ID
|
||||
echo VERSION=$VERSION
|
||||
|
||||
echo "image_id=$IMAGE_ID" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.prepare_tag.outputs.image_id }}:${{ steps.prepare_tag.outputs.version }}
|
||||
build-args: |
|
||||
GITHUB_REPOSITORY=$GITHUB_REPOSITORY
|
||||
GITHUB_REF=$GITHUB_REF
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -37,4 +37,10 @@ node_modules
|
||||
|
||||
examples/*
|
||||
|
||||
ecosystem.config.js
|
||||
ecosystem.config.js
|
||||
.vscode
|
||||
test/credentials/*.json
|
||||
run-tests.sh
|
||||
run-coverage.sh
|
||||
.vscode
|
||||
.env
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
sudo: required
|
||||
language: node_js
|
||||
node_js:
|
||||
- "lts/*"
|
||||
script:
|
||||
- npm test
|
||||
17
.vscode/launch.json
vendored
17
.vscode/launch.json
vendored
@@ -1,17 +0,0 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Program",
|
||||
"program": "${workspaceFolder}/test/index.js",
|
||||
"env": {
|
||||
"NODE_ENV": "test"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
26
Dockerfile
26
Dockerfile
@@ -1,13 +1,23 @@
|
||||
FROM node:lts-alpine
|
||||
FROM --platform=linux/amd64 node:20-alpine as base
|
||||
|
||||
RUN mkdir -p /usr/src/app
|
||||
WORKDIR /usr/src/app
|
||||
RUN apk --update --no-cache add --virtual .builds-deps build-base python3
|
||||
|
||||
WORKDIR /opt/app/
|
||||
|
||||
FROM base as build
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
FROM base
|
||||
|
||||
COPY --from=build /opt/app /opt/app/
|
||||
|
||||
ARG NODE_ENV
|
||||
|
||||
ENV NODE_ENV $NODE_ENV
|
||||
|
||||
COPY package.json /usr/src/app/
|
||||
RUN npm install
|
||||
COPY . /usr/src/app
|
||||
|
||||
CMD [ "npm", "start" ]
|
||||
CMD [ "node", "app.js" ]
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 jambonz
|
||||
Copyright (c) 2018-2024 FirstFive8, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
160
README.md
160
README.md
@@ -1,82 +1,98 @@
|
||||
# jambones-feature-server [](http://travis-ci.org/jambonz/jambones-feature-server)
|
||||
# jambonz-feature-server [](https://github.com/jambonz/jambonz-feature-server/actions/workflows/build.yml)
|
||||
|
||||
This application implements the core feature server of the jambones platform.
|
||||
|
||||
> Note: If you are a developer looking to work on the code please read our [how-to for that](./docs/contributing.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is provided via the [npmjs config](https://www.npmjs.com/package/config) package. The following elements make up the configuration for the application:
|
||||
##### drachtio server location
|
||||
```
|
||||
{
|
||||
"drachtio": {
|
||||
"port": 3001,
|
||||
"secret": "cymru"
|
||||
},
|
||||
```
|
||||
the `drachtio` object specifies the port to listen on for tcp connections from drachtio servers as well as the shared secret that is used to authenticate to the server.
|
||||
Configuration is provided via environment variables:
|
||||
|
||||
> Note: either inbound or [outbound connections](https://drachtio.org/docs#outbound-connections) may be used, depending on the configuration supplied. In production, it is the intent to use outbound connections for easier centralization and clustering of application logic.
|
||||
| variable | meaning | required?|
|
||||
|----------|----------|---------|
|
||||
|AWS_ACCESS_KEY_ID| aws access key id, used for TTS/STT as well SNS notifications|no|
|
||||
|AWS_REGION| aws region| no|
|
||||
|AWS_SECRET_ACCESS_KEY| aws secret access key, used per above|no|
|
||||
|AWS_SNS_TOPIC_ARN| aws sns topic arn that scale-in lifecycle notifications will be published to|no|
|
||||
|DRACHTIO_HOST| ip address of drachtio server (typically '127.0.0.1')|yes|
|
||||
|DRACHTIO_PORT| listening port of drachtio server for control connections (typically 9022)|yes|
|
||||
|DRACHTIO_SECRET| shared secret|yes|
|
||||
|ENABLE_METRICS| if 1, metrics will be generated|no|
|
||||
|ENCRYPTION_SECRET| secret for credential encryption(JWT_SECRET is deprecated) |yes|
|
||||
|GOOGLE_APPLICATION_CREDENTIALS| path to gcp service key file|yes|
|
||||
|HTTP_PORT| tcp port to listen on for API requests from jambonz-api-server|yes|
|
||||
|HTTP_IP| IP Address for API requests from jambonz-api-server |no|
|
||||
|JAMBONES_GATHER_EARLY_HINTS_MATCH| if true and hints are provided, gather will opportunistically review interim transcripts if possible to reduce ASR latency |no|
|
||||
|JAMBONES_FREESWITCH| IP:port:secret for Freeswitch server (e.g. '127.0.0.1:8021:JambonzR0ck$'|yes|
|
||||
|JAMBONES_LOGLEVEL| log level for application, 'info' or 'debug'|no|
|
||||
|JAMBONES_MYSQL_HOST| mysql host|yes|
|
||||
|JAMBONES_MYSQL_USER| mysql username|yes|
|
||||
|JAMBONES_MYSQL_PASSWORD| mysql password|yes|
|
||||
|JAMBONES_MYSQL_DATABASE| mysql data|yes|
|
||||
|JAMBONES_MYSQL_CONNECTION_LIMIT| mysql connection limit |no|
|
||||
|JAMBONES_NETWORK_CIDR| CIDR of private network that feature server is running in (e.g. '172.31.0.0/16')|yes|
|
||||
|JAMBONES_REDIS_HOST| redis host|yes|
|
||||
|JAMBONES_REDIS_PORT|redis port|yes|
|
||||
|JAMBONES_SBCS| list of IP addresses (on the internal network) of SBCs, comma-separated|yes|
|
||||
|STATS_HOST| ip address of metrics host (usually '127.0.0.1' since telegraf is installed locally|no|
|
||||
|STATS_PORT| listening port for metrics host|no|
|
||||
|STATS_PROTOCOL| 'tcp' or 'udp'|no|
|
||||
|STATS_TELEGRAF| if 1, metrics will be generated in telegraf format|no|
|
||||
|JAMBONZ_RECORD_WS_BASE_URL| recording websocket URL to send the recording audio|no|
|
||||
|JAMBONZ_RECORD_WS_USERNAME| recording websocket username|no|
|
||||
|JAMBONZ_RECORD_WS_PASSWORD| recording websocket password|no|
|
||||
|ANCHOR_MEDIA_ALWAYS| keep media on media server|no|
|
||||
|JAMBONZ_DISABLE_DIAL_PAI_HEADER| control P-Asserted-Identity header on B-Leg|no|
|
||||
|
||||
##### freeswitch location
|
||||
```
|
||||
"freeswitch: {
|
||||
"address": "127.0.0.1",
|
||||
"port": 8021,
|
||||
"secret": "ClueCon"
|
||||
},
|
||||
```
|
||||
the `freeswitch` property specifies the location of the freeswitch server to use for media handling.
|
||||
|
||||
##### application log level
|
||||
```
|
||||
"logging": {
|
||||
"level": "info"
|
||||
}
|
||||
```
|
||||
##### mysql server location
|
||||
Login credentials for the mysql server databas.
|
||||
```
|
||||
"mysql": {
|
||||
"host": "127.0.0.1",
|
||||
"user": "jambones",
|
||||
"password": "jambones",
|
||||
"database": "jambones"
|
||||
}
|
||||
```
|
||||
##### redis server location
|
||||
Login credentials for the redis server databas.
|
||||
```
|
||||
"redis": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 6379
|
||||
}
|
||||
```
|
||||
|
||||
##### port to listen on for HTTP API requests
|
||||
The HTTP listen port can be set by the `HTTP_PORT` environment variable, but it not set the default port will be taken from the configuration file.
|
||||
|
||||
```
|
||||
"defaultHttpPort": 3000,
|
||||
```
|
||||
|
||||
##### REST-initiated outdials
|
||||
When an outdial is triggered via the REST API, the application needs to select a drachtio sip server to generate the INVITE, and it needs to know the IP addresses of the SBC(s) to send the outbound call through. Both are provided as arrays in the configuration file, and if more than one is supplied they will be used in a round-robin fashion.
|
||||
|
||||
```
|
||||
"outdials": {
|
||||
"drachtio": [
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 9022,
|
||||
"secret": "cymru"
|
||||
}
|
||||
],
|
||||
"sbc": ["127.0.0.1:5060"]
|
||||
}
|
||||
### running under pm2
|
||||
Typically, this application runs under [pm2](https://pm2.io) using an [ecosystem.config.js](https://pm2.keymetrics.io/docs/usage/application-declaration/) file similar to this:
|
||||
```js
|
||||
module.exports = {
|
||||
apps : [
|
||||
{
|
||||
name: 'jambonz-feature-server',
|
||||
cwd: '/home/admin/apps/jambonz-feature-server',
|
||||
script: 'app.js',
|
||||
instance_var: 'INSTANCE_ID',
|
||||
out_file: '/home/admin/.pm2/logs/jambonz-feature-server.log',
|
||||
err_file: '/home/admin/.pm2/logs/jambonz-feature-server.log',
|
||||
exec_mode: 'fork',
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: '1G',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
GOOGLE_APPLICATION_CREDENTIALS: '/home/admin/credentials/gcp.json',
|
||||
AWS_ACCESS_KEY_ID: 'XXXXXXXXXXXX',
|
||||
AWS_SECRET_ACCESS_KEY: 'YYYYYYYYYYYYYYYYYYYYY',
|
||||
AWS_REGION: 'us-west-1',
|
||||
ENABLE_METRICS: 1,
|
||||
STATS_HOST: '127.0.0.1',
|
||||
STATS_PORT: 8125,
|
||||
STATS_PROTOCOL: 'tcp',
|
||||
STATS_TELEGRAF: 1,
|
||||
AWS_SNS_TOPIC_ARN: 'arn:aws:sns:us-west-1:xxxxxxxxxxx:terraform-20201107200347128600000002',
|
||||
JAMBONES_NETWORK_CIDR: '172.31.0.0/16',
|
||||
JAMBONES_MYSQL_HOST: 'aurora-cluster-jambonz.cluster-yyyyyyyyyyy.us-west-1.rds.amazonaws.com',
|
||||
JAMBONES_MYSQL_USER: 'admin',
|
||||
JAMBONES_MYSQL_PASSWORD: 'foobarbz',
|
||||
JAMBONES_MYSQL_DATABASE: 'jambones',
|
||||
JAMBONES_MYSQL_CONNECTION_LIMIT: 10,
|
||||
JAMBONES_REDIS_HOST: 'jambonz.zzzzzzz.0001.usw1.cache.amazonaws.com',
|
||||
JAMBONES_REDIS_PORT: 6379,
|
||||
JAMBONES_LOGLEVEL: 'debug',
|
||||
HTTP_PORT: 3000,
|
||||
DRACHTIO_HOST: '127.0.0.1',
|
||||
DRACHTIO_PORT: 9022,
|
||||
DRACHTIO_SECRET: 'sharedsecret',
|
||||
JAMBONES_SBCS: '172.31.32.10',
|
||||
JAMBONES_FREESWITCH: '127.0.0.1:8021:sharedsecret'
|
||||
}
|
||||
}]
|
||||
};
|
||||
```
|
||||
|
||||
#### Running the test suite
|
||||
The test suite currently only consists of JSON-parsing unit tests. A full end-to-end sip test suite should be added.
|
||||
```
|
||||
npm test
|
||||
```
|
||||
|
||||
Please [see this](./docs/contributing.md#run-the-regression-test-suite).
|
||||
257
app.js
257
app.js
@@ -1,77 +1,220 @@
|
||||
const assert = require('assert');
|
||||
assert.ok(process.env.JAMBONES_MYSQL_HOST &&
|
||||
process.env.JAMBONES_MYSQL_USER &&
|
||||
process.env.JAMBONES_MYSQL_PASSWORD &&
|
||||
process.env.JAMBONES_MYSQL_DATABASE, 'missing JAMBONES_MYSQL_XXX env vars');
|
||||
assert.ok(process.env.DRACHTIO_PORT || process.env.DRACHTIO_HOST, 'missing DRACHTIO_PORT env var');
|
||||
assert.ok(process.env.DRACHTIO_SECRET, 'missing DRACHTIO_SECRET env var');
|
||||
assert.ok(process.env.JAMBONES_FREESWITCH, 'missing JAMBONES_FREESWITCH env var');
|
||||
assert.ok(process.env.JAMBONES_REDIS_HOST, 'missing JAMBONES_REDIS_HOST env var');
|
||||
assert.ok(process.env.JAMBONES_NETWORK_CIDR, 'missing JAMBONES_SUBNET env var');
|
||||
const {
|
||||
DRACHTIO_PORT,
|
||||
DRACHTIO_HOST,
|
||||
DRACHTIO_SECRET,
|
||||
JAMBONES_OTEL_SERVICE_NAME,
|
||||
JAMBONES_LOGLEVEL,
|
||||
JAMBONES_CLUSTER_ID,
|
||||
JAMBONZ_CLEANUP_INTERVAL_MINS,
|
||||
getCleanupIntervalMins,
|
||||
K8S,
|
||||
NODE_ENV,
|
||||
checkEnvs,
|
||||
} = require('./lib/config');
|
||||
|
||||
checkEnvs();
|
||||
|
||||
const Srf = require('drachtio-srf');
|
||||
const srf = new Srf();
|
||||
const PORT = process.env.HTTP_PORT || 3000;
|
||||
const opts = Object.assign({
|
||||
timestamp: () => {return `, "time": "${new Date().toISOString()}"`;}
|
||||
}, {level: process.env.JAMBONES_LOGLEVEL || 'info'});
|
||||
const logger = require('pino')(opts);
|
||||
const tracer = require('./tracer')(JAMBONES_OTEL_SERVICE_NAME);
|
||||
const api = require('@opentelemetry/api');
|
||||
srf.locals = {...srf.locals, otel: {tracer, api}};
|
||||
|
||||
const opts = {
|
||||
level: JAMBONES_LOGLEVEL
|
||||
};
|
||||
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 {
|
||||
initLocals,
|
||||
normalizeNumbers,
|
||||
retrieveApplication,
|
||||
invokeWebCallback
|
||||
} = require('./lib/middleware')(srf, logger);
|
||||
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);
|
||||
});
|
||||
|
||||
// HTTP
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
app.locals.logger = logger;
|
||||
const httpRoutes = require('./lib/http-routes');
|
||||
|
||||
const InboundCallSession = require('./lib/session/inbound-call-session');
|
||||
|
||||
if (process.env.DRACHTIO_HOST) {
|
||||
srf.connect({host: process.env.DRACHTIO_HOST, port: process.env.DRACHTIO_PORT, secret: process.env.DRACHTIO_SECRET });
|
||||
srf.on('connect', (err, hp) => {
|
||||
logger.info(`connected to drachtio listening on ${hp}`);
|
||||
});
|
||||
}
|
||||
else {
|
||||
logger.info(`listening for drachtio requests on port ${process.env.DRACHTIO_PORT}`);
|
||||
srf.listen({port: process.env.DRACHTIO_PORT, secret: process.env.DRACHTIO_SECRET});
|
||||
}
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
// 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');
|
||||
});
|
||||
}
|
||||
|
||||
srf.use('invite', [initLocals, normalizeNumbers, retrieveApplication, invokeWebCallback]);
|
||||
// Init services
|
||||
const writeSystemAlerts = srf.locals?.writeSystemAlerts;
|
||||
if (writeSystemAlerts) {
|
||||
writeSystemAlerts({
|
||||
system_component: FEATURE_SERVER,
|
||||
state : SystemState.Online,
|
||||
fields : {
|
||||
detail: `feature-server with process_id ${process.pid} started`,
|
||||
host: srf.locals?.ipv4
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
srf.invite((req, res) => {
|
||||
const session = new InboundCallSession(req, res);
|
||||
const {
|
||||
initLocals,
|
||||
createRootSpan,
|
||||
handleSipRec,
|
||||
getAccountDetails,
|
||||
normalizeNumbers,
|
||||
retrieveApplication,
|
||||
invokeWebCallback
|
||||
} = require('./lib/middleware')(srf, logger);
|
||||
|
||||
const InboundCallSession = require('./lib/session/inbound-call-session');
|
||||
const SipRecCallSession = require('./lib/session/siprec-call-session');
|
||||
|
||||
srf.use('invite', [
|
||||
initLocals,
|
||||
createRootSpan,
|
||||
handleSipRec,
|
||||
getAccountDetails,
|
||||
normalizeNumbers,
|
||||
retrieveApplication,
|
||||
invokeWebCallback
|
||||
]);
|
||||
|
||||
srf.invite(async(req, res) => {
|
||||
const isSipRec = !!req.locals.siprec;
|
||||
const session = isSipRec ? new SipRecCallSession(req, res) : new InboundCallSession(req, res);
|
||||
if (isSipRec) await session.answerSipRecCall();
|
||||
session.exec();
|
||||
});
|
||||
|
||||
// HTTP
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.use('/', httpRoutes);
|
||||
app.use((err, req, res, next) => {
|
||||
logger.error(err, 'burped error');
|
||||
res.status(err.status || 500).json({msg: err.message});
|
||||
const sessionTracker = srf.locals.sessionTracker = require('./lib/session/session-tracker');
|
||||
sessionTracker.on('idle', () => {
|
||||
if (srf.locals.lifecycleEmitter.operationalState === LifeCycleEvents.ScaleIn) {
|
||||
logger.info('scale-in complete now that calls have dried up');
|
||||
srf.locals.lifecycleEmitter.scaleIn();
|
||||
}
|
||||
});
|
||||
app.listen(PORT);
|
||||
const getCount = () => sessionTracker.count;
|
||||
|
||||
logger.info(`listening for HTTP requests on port ${PORT}, serviceUrl is ${srf.locals.serviceUrl}`);
|
||||
let httpServer;
|
||||
|
||||
const sessionTracker = require('./lib/session/session-tracker');
|
||||
setInterval(() => {
|
||||
const monInterval = setInterval(async() => {
|
||||
srf.locals.stats.gauge('fs.sip.calls.count', sessionTracker.count);
|
||||
}, 5000);
|
||||
try {
|
||||
const systemInformation = await srf.locals.dbHelpers.lookupSystemInformation();
|
||||
if (systemInformation && 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') {
|
||||
clearInterval(monInterval);
|
||||
logger.error('all tests complete');
|
||||
}
|
||||
else logger.error({err}, 'Error checking system log level in database');
|
||||
}
|
||||
}, 20000);
|
||||
|
||||
module.exports = {srf, logger};
|
||||
const disconnect = () => {
|
||||
return new Promise ((resolve) => {
|
||||
httpServer?.on('close', resolve);
|
||||
httpServer?.close();
|
||||
srf.disconnect();
|
||||
srf.removeAllListeners();
|
||||
srf.locals.mediaservers?.forEach((ms) => ms.disconnect());
|
||||
});
|
||||
};
|
||||
process.on('SIGTERM', handle);
|
||||
process.on('SIGINT', handle);
|
||||
|
||||
async function handle(signal) {
|
||||
const {removeFromSet} = srf.locals.dbHelpers;
|
||||
srf.locals.disabled = true;
|
||||
logger.info(`got signal ${signal}`);
|
||||
const writeSystemAlerts = srf.locals?.writeSystemAlerts;
|
||||
if (writeSystemAlerts) {
|
||||
// it has to be synchronous call, or else by the time system saves the app terminates
|
||||
await writeSystemAlerts({
|
||||
system_component: FEATURE_SERVER,
|
||||
state : SystemState.Offline,
|
||||
fields : {
|
||||
detail: `feature-server with process_id ${process.pid} stopped, signal ${signal}`,
|
||||
host: srf.locals?.ipv4
|
||||
}
|
||||
});
|
||||
}
|
||||
const setName = `${(JAMBONES_CLUSTER_ID || 'default')}:active-fs`;
|
||||
const fsServiceUrlSetName = `${(JAMBONES_CLUSTER_ID || 'default')}:fs-service-url`;
|
||||
if (setName && srf.locals.localSipAddress) {
|
||||
logger.info(`got signal ${signal}, removing ${srf.locals.localSipAddress} from set ${setName}`);
|
||||
removeFromSet(setName, srf.locals.localSipAddress);
|
||||
}
|
||||
if (fsServiceUrlSetName && srf.locals.serviceUrl) {
|
||||
logger.info(`got signal ${signal}, removing ${srf.locals.serviceUrl} from set ${fsServiceUrlSetName}`);
|
||||
removeFromSet(fsServiceUrlSetName, srf.locals.serviceUrl);
|
||||
}
|
||||
removeFromSet(FS_UUID_SET_NAME, srf.locals.fsUUID);
|
||||
if (K8S) {
|
||||
srf.locals.lifecycleEmitter.operationalState = LifeCycleEvents.ScaleIn;
|
||||
}
|
||||
if (getCount() === 0) {
|
||||
logger.info('no calls in progress, exiting');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (JAMBONZ_CLEANUP_INTERVAL_MINS) {
|
||||
const {clearFiles} = require('./lib/utils/cron-jobs');
|
||||
|
||||
/* cleanup orphaned files or channels every so often */
|
||||
setInterval(async() => {
|
||||
try {
|
||||
await clearFiles();
|
||||
} catch (err) {
|
||||
logger.error({err}, 'app.js: error clearing files');
|
||||
}
|
||||
}, getCleanupIntervalMins());
|
||||
}
|
||||
|
||||
module.exports = {srf, logger, disconnect};
|
||||
|
||||
29
bin/k8s-pre-stop-hook.js
Normal file
29
bin/k8s-pre-stop-hook.js
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
const bent = require('bent');
|
||||
const getJSON = bent('json');
|
||||
const {PORT} = require('../lib/config')
|
||||
|
||||
const sleep = (ms) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
|
||||
(async function() {
|
||||
|
||||
try {
|
||||
do {
|
||||
const obj = await getJSON(`http://127.0.0.1:${PORT}/`);
|
||||
const {calls} = obj;
|
||||
if (calls === 0) {
|
||||
console.log('no calls on the system, we can exit');
|
||||
process.exit(0);
|
||||
}
|
||||
else {
|
||||
console.log(`waiting for ${calls} to exit..`);
|
||||
}
|
||||
await sleep(10000);
|
||||
} while (1);
|
||||
} catch (err) {
|
||||
console.error(err, 'Error querying health endpoint');
|
||||
process.exit(-1);
|
||||
}
|
||||
})();
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"drachtio": {
|
||||
"port": 3010,
|
||||
"secret": "cymru"
|
||||
},
|
||||
"freeswitch": {
|
||||
"address": "127.0.0.1",
|
||||
"port": 8021,
|
||||
"secret": "ClueCon"
|
||||
},
|
||||
"logging": {
|
||||
"level": "info"
|
||||
},
|
||||
"mysql": {
|
||||
"host": "localhost",
|
||||
"user": "jambones",
|
||||
"password": "jambones",
|
||||
"database": "jambones"
|
||||
},
|
||||
"redis": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 6379
|
||||
},
|
||||
"defaultHttpPort": 3000,
|
||||
"outdials": {
|
||||
"drachtio": [
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 9022,
|
||||
"secret": "cymru"
|
||||
}
|
||||
],
|
||||
"sbc": ["127.0.0.1:5060"]
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"drachtio": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 9060,
|
||||
"secret": "cymru"
|
||||
},
|
||||
"logging": {
|
||||
"level": "debug"
|
||||
},
|
||||
"mysql": {
|
||||
"host": "localhost",
|
||||
"user": "jambones_test",
|
||||
"password": "jambones_test",
|
||||
"database": "jambones_test"
|
||||
}
|
||||
}
|
||||
178
data/example-voicemail-greetings.json
Normal file
178
data/example-voicemail-greetings.json
Normal file
@@ -0,0 +1,178 @@
|
||||
{
|
||||
"en-US": [
|
||||
"call has been forwarded",
|
||||
"at the beep",
|
||||
"at the tone",
|
||||
"leave a message",
|
||||
"leave me a message",
|
||||
"not available",
|
||||
"can't take your call",
|
||||
"will get back to you",
|
||||
"I'll get back to you",
|
||||
"we are unable",
|
||||
"Unable to take your call now",
|
||||
"I'll reply soon",
|
||||
"I'll call back",
|
||||
"I'll reach out to you as soon as possible",
|
||||
"Leave a message",
|
||||
"Away from phone",
|
||||
"Not available now",
|
||||
"I'll return call",
|
||||
"On another call",
|
||||
"Currently on another call",
|
||||
"I will return call later",
|
||||
"Busy please leave message",
|
||||
"Message will be returned promptly",
|
||||
"Currently unavailable to answer",
|
||||
"Planning to return your call soon",
|
||||
"Apologies for missing your call",
|
||||
"Not by the phone at the moment",
|
||||
"Expecting to return your call",
|
||||
"Currently not accessible",
|
||||
"Intend to call back",
|
||||
"Appreciate your patience!",
|
||||
"Engaged in another conversation",
|
||||
"I Will respond promptly",
|
||||
"Kindly leave a message",
|
||||
"Currently occupied leave a message",
|
||||
"Unfortunately unable to answer right now",
|
||||
"Occupied at the moment",
|
||||
"Not present leave a message",
|
||||
"Regrettably unavailable kindly leave a message",
|
||||
"Will ensure a prompt response to your message",
|
||||
"Currently engaged",
|
||||
"Will return your call at the earliest opportunity",
|
||||
"Your message will receive my prompt attention",
|
||||
"I'll respond as soon as I can",
|
||||
"Your message is important please leave it after the beep",
|
||||
"Away from the phone at the moment",
|
||||
"Unable to answer right now",
|
||||
"Engaged in another task",
|
||||
"Not by the phone presently",
|
||||
"I'll respond at my earliest convenience",
|
||||
"Away from the phone momentarily",
|
||||
"I'll return your call shortly",
|
||||
"Currently not able to answer",
|
||||
"Your message is important please leave it after the tone",
|
||||
"I'm unable to take your call right now",
|
||||
"Please leave your message for me",
|
||||
"I'll get back to you soon",
|
||||
"Your call has been missed",
|
||||
"Please leave a detailed message for me to respond to",
|
||||
"Leave a message I'll make sure to respond",
|
||||
"Feel free to leave a message",
|
||||
"Your call is important to me",
|
||||
"I'll get back to you shortly",
|
||||
"Your message will be attended to promptly",
|
||||
"Not available at the moment",
|
||||
"I'll be sure to get back to you",
|
||||
"I'll call you back soon",
|
||||
"I'll ensure a prompt response",
|
||||
"Sorry for the inconvenience",
|
||||
"I'll return your call",
|
||||
"I'll make sure to get back to you",
|
||||
"I'll call you back shortly",
|
||||
"I'll return your call as soon as possible",
|
||||
"Apologies for the inconvenience leave your message",
|
||||
"Your call is appreciated",
|
||||
"I'm unavailable to answer",
|
||||
"I'm currently away",
|
||||
"I'll return your call as soon as I can",
|
||||
"I'm away from the phone",
|
||||
"I'm currently unavailable to take your call",
|
||||
"Sorry for missing your call",
|
||||
"I'll ensure it receives my immediate attention",
|
||||
"I'm away from the phone momentarily",
|
||||
"I'll reach out to you shortly",
|
||||
"Apologies for the inconvenience",
|
||||
"Currently occupied",
|
||||
"Unable to answer your call at the moment",
|
||||
"I'll make sure to follow up with you",
|
||||
"Sorry for not being available",
|
||||
"I'll reach out to you as soon as I can",
|
||||
"I'm currently engaged",
|
||||
"I'm currently busy",
|
||||
"I'm currently unavailable",
|
||||
"I'll respond to you at my earliest convenience",
|
||||
"Your message is appreciated",
|
||||
"I'll get back to you promptly",
|
||||
"I'll get back to you without delay",
|
||||
"Currently away from the phone",
|
||||
"I'll return your call at my earliest opportunity",
|
||||
"Sorry for the missed call",
|
||||
"I'll make sure to address your concerns",
|
||||
"Please provide your details for a callback",
|
||||
"I'll make every effort to respond promptly",
|
||||
"I'll ensure it's attended to promptly",
|
||||
"Away from the phone temporarily",
|
||||
"I'll get back to you as soon as I return",
|
||||
"Currently not in a position to answer your call",
|
||||
"Your call cannot be answered at the moment",
|
||||
"I'll ensure to respond as soon as I'm able",
|
||||
"Your call is important please leave a message",
|
||||
"Unable to answer right now please leave your message",
|
||||
"Currently not accessible intending to return your call",
|
||||
"I'll respond promptly to your message",
|
||||
"leave a memo",
|
||||
"please leave a memo"
|
||||
],
|
||||
"es-ES": [
|
||||
"le pasamos la llamada",
|
||||
"después del bip",
|
||||
"después del tono",
|
||||
"deja un mensaje",
|
||||
"déjame un mensaje",
|
||||
"no estamos disponibles",
|
||||
"no estoy disponible",
|
||||
"ahora no puedo",
|
||||
"no puedo contestar",
|
||||
"no le puedo contestar",
|
||||
"me pondré en contacto",
|
||||
"nos pondremos en contacto",
|
||||
"ahora no estamos disponibles",
|
||||
"no estamos disponibles"
|
||||
],
|
||||
"ca-ES": [
|
||||
"passem la seva trucada",
|
||||
"després del bip",
|
||||
"després del to",
|
||||
"deixi un missatge",
|
||||
"deixa un missatge",
|
||||
"deixim un missatge",
|
||||
"no estem disponibles",
|
||||
"no estem a l'oficina",
|
||||
"no estic disponible",
|
||||
"ara no puc",
|
||||
"no puc contestar",
|
||||
"no puc respondre",
|
||||
"no li puc respondre",
|
||||
"em posaré en contacte",
|
||||
"ens posarem en contacto",
|
||||
"ara no estem disponibles",
|
||||
"no hi som"
|
||||
],
|
||||
"de-DE": [
|
||||
"nicht erreichbar",
|
||||
"nnruf wurde weitergeleitet",
|
||||
"beim piepsen",
|
||||
"am ton",
|
||||
"eine nachricht hinterlassen",
|
||||
"hinterlasse mir eine Nachricht",
|
||||
"nicht verfügbar",
|
||||
"kann ihren anruf nicht entgegennehmen",
|
||||
"wird sich bei Ihnen melden",
|
||||
"ich melde mich bei dir",
|
||||
"wir können nicht"
|
||||
],
|
||||
"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"
|
||||
]
|
||||
}
|
||||
123
docs/contributing.md
Normal file
123
docs/contributing.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Contributors are welcome!
|
||||
|
||||
So, you want to hack on jambonz? Maybe add some features, maybe help fix some bugs? Awesome, welcome aboard!
|
||||
|
||||
This brief document should get you started. Here you will find instructions showing how to set up your laptop to run the regression test suite (which you should always run before committing any changes), as well as some basic info on the structure of the code.
|
||||
|
||||
## Getting oriented
|
||||
|
||||
First of all, you are in the right place to begin hacking on jambonz. The jambonz-feature-server app is kinda the center of the universe for jambonz. Most of the core logic in jambonz is implemented here: things like the [webhook verbs](../lib/tasks), [session management](../lib/session), and the [client-side webhook implementation](../lib/utils/http-requestor.js). A common thing you might want to do, for instance, is to add support for an all-new verb, and this code base is where would do that.
|
||||
|
||||
This jambonz-feature-server app works together quite closely with a [drachtio server](https://github.com/drachtio/drachtio-server) and a Freeswitch. In fact, these three components are bundled together into a single VM/instance (or a Deployment, in Kubernetes) that we more generally refer to as "Feature Server". The Feature Server is a horizontally-scalable unit that is deployed behind the public-facing SBC elements of a jambonz cluster (the SBC is itself a separately scalable unit). The drachtio-server handles the SIP signaling, the Freeswitch handles media operations and speech vendor integration, and the jambonz-feature-server app orchestrates all of it via the use of [drachtio-srf](https://github.com/drachtio/drachtio-srf) and [drachtio-fsmrf](https://github.com/drachtio/drachtio-fsmrf).
|
||||
|
||||
## How to do things
|
||||
|
||||
First of all, please join our [slack channel](https://joinslack.jambonz.org) in order to coordinate with us on the work, i.e. to notify us of what you are doing and make sure that no one else is already working on the same thing.
|
||||
|
||||
To prepare to make changes, please fork the repo to your own Github account, make changes, test them on your own running jambonz cluster, then run the regression test suite and lint check before giving us a PR.
|
||||
|
||||
### lint
|
||||
|
||||
We have some opinionated conventions that you must follow - see our [eslintrc.json](../.eslintrc.json) for details. Make sure your code passes by running:
|
||||
|
||||
```bash
|
||||
npm run jslint
|
||||
```
|
||||
|
||||
### test suite
|
||||
|
||||
#### Generate speech credentials and create run-tests.sh
|
||||
|
||||
The test suite also requires you to provide speech credentials for both GCP and AWS. You will want to create a new file named `run-tests.sh` in the project folder. Make the file executable and then copy in the text below, substituting your speech credentials where indicated:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
GCP_JSON_KEY='{"type":"service_account","project_id":"...etc"}' \
|
||||
AWS_ACCESS_KEY_ID='your-aws-access-key-id' \
|
||||
AWS_SECRET_ACCESS_KEY='your-aws-secret-access-key' \
|
||||
AWS_REGION='us-east-1' \
|
||||
JWT_SECRET='foobar' \
|
||||
npm test
|
||||
```
|
||||
>> Note: The project's .gitignore file prevents this file from being sent to Github, so you do not need to worry about exposing your credentials. Just make sure you name if run-tests.sh and create it in the project folder
|
||||
|
||||
The GCP credential is the JSON service key in stringified format.
|
||||
|
||||
#### Install Docker
|
||||
|
||||
The test suite also requires [Docker](https://www.docker.com/) and docker-compose to be installed on your laptop. Docker is used to set up a network with all of the elements required to test the jambonz-feature-server in a black-box type of fashion.
|
||||
|
||||
Once you have docker installed, you can optionally make sure everything Docker-wise is working properly by running this command from the project folder:
|
||||
|
||||
```bash
|
||||
docker-compose -f test/docker-compose-testbed.yaml up -d
|
||||
```
|
||||
|
||||
This may take several minutes to complete, mainly because the mysql schema needs to be installed and seeded, but if successful the output should look like this:
|
||||
|
||||
```bash
|
||||
$ docker-compose -f test/docker-compose-testbed.yaml up -d
|
||||
Creating network "test_fs" with driver "bridge"
|
||||
Creating test_webhook-transcribe_1 ... done
|
||||
Creating test_webhook-decline_1 ... done
|
||||
Creating test_mysql_1 ... done
|
||||
Creating test_docker-host_1 ... done
|
||||
Creating test_webhook-gather_1 ... done
|
||||
Creating test_webhook-say_1 ... done
|
||||
Creating test_freeswitch_1 ... done
|
||||
Creating test_influxdb_1 ... done
|
||||
Creating test_redis_1 ... done
|
||||
Creating test_drachtio_1 ... done
|
||||
```
|
||||
|
||||
At that point, you can run `docker ps` to see all of the containers running
|
||||
|
||||
```bash
|
||||
docker ps
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
abbc3594f390 drachtio/drachtio-server:latest "/entrypoint.sh drac…" About a minute ago Up About a minute 0.0.0.0:9060->9022/tcp test_drachtio_1
|
||||
1f384a274f87 redis:5-alpine "docker-entrypoint.s…" 2 minutes ago Up 2 minutes 0.0.0.0:16379->6379/tcp test_redis_1
|
||||
78d0bb6ec9b1 influxdb:1.8 "/entrypoint.sh infl…" 2 minutes ago Up 2 minutes 0.0.0.0:8086->8086/tcp test_influxdb_1
|
||||
9616ff790709 jambonz/webhook-test-scaffold:latest "/entrypoint.sh" 2 minutes ago Up 2 minutes 0.0.0.0:3102->3000/tcp test_webhook-gather_1
|
||||
7323ab273ff4 drachtio/drachtio-freeswitch-mrf:v1.10.1-full "/entrypoint.sh free…" 2 minutes ago Up 2 minutes (healthy) 0.0.0.0:8022->8021/tcp test_freeswitch_1
|
||||
e45e7d28dbc7 mysql:5.7 "docker-entrypoint.s…" 2 minutes ago Up 2 minutes (healthy) 33060/tcp, 0.0.0.0:3360->3306/tcp test_mysql_1
|
||||
b626e5f3067e qoomon/docker-host "/entrypoint.sh" 2 minutes ago Up 2 minutes test_docker-host_1
|
||||
b0a94b5e8941 jambonz/webhook-test-scaffold:latest "/entrypoint.sh" 2 minutes ago Up 2 minutes 0.0.0.0:3101->3000/tcp test_webhook-say_1
|
||||
f80adda48eb5 jambonz/webhook-test-scaffold:latest "/entrypoint.sh" 2 minutes ago Up 2 minutes 0.0.0.0:3103->3000/tcp test_webhook-transcribe_1
|
||||
223db4a9c670 jambonz/webhook-test-scaffold:latest "/entrypoint.sh" 2 minutes ago Up 2 minutes 0.0.0.0:3100->3000/tcp test_webhook-decline_1
|
||||
```
|
||||
|
||||
#### Run the regression test suite
|
||||
|
||||
The test suite has a dependency that the mysql client is installed on your laptop/machine where the test will be run. This is needed in order to seed the mysql database that is running in the docker network.
|
||||
|
||||
Assuming you have installed the mysql client, and done the above steps, you should now be able to run the tests:
|
||||
|
||||
```bash
|
||||
./run-tests.sh
|
||||
```
|
||||
|
||||
If the docker network has not been started (as described above) it will start now, and this will take a minute or two. Otherwise, the test suite will start running immediately.
|
||||
|
||||
In evaluating the test results, be advised that the output is fairly verbose, and also in the process of shutting down once the tests are complete you will see a bunch of errors from redis (`@jambonz/realtimedb-helpers - redis error`). You can ignore these errors, they are just spit out by jambonz-feature-server as the test environment is torn down and it tries and fails to reconnect to redis.
|
||||
|
||||
The final output will indicate the number of tests run and passed:
|
||||
|
||||
```bash
|
||||
1..28
|
||||
# tests 28
|
||||
# pass 28
|
||||
|
||||
# ok
|
||||
```
|
||||
|
||||
#### Adding your own tests
|
||||
|
||||
Running a successful regression test means you haven't broken anything - Great!
|
||||
|
||||
It doesn't, of course, mean that your shiny new feature or bugfix works. Adding a new test case to the suite is (unfortunately) non-trivial. We will add more documentation in the future with a how-to guide on that, but be advised it does require knowledge of the SIP protocol and the [SIPp](http://sipp.sourceforge.net/doc/reference.html) tool.
|
||||
|
||||
For now, if you are unable to add tests to the regression suite, please do test your feature as thoroughly as you can on your own jambonz cluster before giving us a pull request.
|
||||
|
||||
|
||||
|
||||
235
lib/config.js
Normal file
235
lib/config.js
Normal file
@@ -0,0 +1,235 @@
|
||||
const assert = require('assert');
|
||||
|
||||
const checkEnvs = () => {
|
||||
assert.ok(process.env.JAMBONES_MYSQL_HOST &&
|
||||
process.env.JAMBONES_MYSQL_USER &&
|
||||
process.env.JAMBONES_MYSQL_PASSWORD &&
|
||||
process.env.JAMBONES_MYSQL_DATABASE, 'missing JAMBONES_MYSQL_XXX env vars');
|
||||
assert.ok(process.env.DRACHTIO_PORT || process.env.DRACHTIO_HOST, 'missing DRACHTIO_PORT env var');
|
||||
assert.ok(process.env.DRACHTIO_SECRET, 'missing DRACHTIO_SECRET env var');
|
||||
assert.ok(process.env.JAMBONES_FREESWITCH, 'missing JAMBONES_FREESWITCH env var');
|
||||
if (process.env.JAMBONES_REDIS_SENTINELS) {
|
||||
assert.ok(process.env.JAMBONES_REDIS_SENTINEL_MASTER_NAME,
|
||||
'missing JAMBONES_REDIS_SENTINEL_MASTER_NAME env var, JAMBONES_REDIS_SENTINEL_PASSWORD env var is optional');
|
||||
} else {
|
||||
assert.ok(process.env.JAMBONES_REDIS_HOST, 'missing JAMBONES_REDIS_HOST env var');
|
||||
}
|
||||
assert.ok(process.env.JAMBONES_NETWORK_CIDR || process.env.K8S, 'missing JAMBONES_SUBNET env var');
|
||||
};
|
||||
|
||||
const NODE_ENV = process.env.NODE_ENV;
|
||||
|
||||
/* database mySQL */
|
||||
const JAMBONES_MYSQL_HOST = process.env.JAMBONES_MYSQL_HOST;
|
||||
const JAMBONES_MYSQL_USER = process.env.JAMBONES_MYSQL_USER;
|
||||
const JAMBONES_MYSQL_PASSWORD = process.env.JAMBONES_MYSQL_PASSWORD;
|
||||
const JAMBONES_MYSQL_DATABASE = process.env.JAMBONES_MYSQL_DATABASE;
|
||||
const JAMBONES_MYSQL_PORT = parseInt(process.env.JAMBONES_MYSQL_PORT, 10) || 3306;
|
||||
const JAMBONES_MYSQL_REFRESH_TTL = parseInt(process.env.JAMBONES_MYSQL_REFRESH_TTL, 10) || 0;
|
||||
const JAMBONES_MYSQL_CONNECTION_LIMIT = parseInt(process.env.JAMBONES_MYSQL_CONNECTION_LIMIT, 10) || 10;
|
||||
|
||||
/* gather and hints */
|
||||
const JAMBONES_GATHER_EARLY_HINTS_MATCH = process.env.JAMBONES_GATHER_EARLY_HINTS_MATCH;
|
||||
const JAMBONZ_GATHER_EARLY_HINTS_MATCH = process.env.JAMBONZ_GATHER_EARLY_HINTS_MATCH;
|
||||
const JAMBONES_GATHER_CLEAR_GLOBAL_HINTS_ON_EMPTY_HINTS = process.env.JAMBONES_GATHER_CLEAR_GLOBAL_HINTS_ON_EMPTY_HINTS;
|
||||
|
||||
const SMPP_URL = process.env.SMPP_URL;
|
||||
|
||||
/* drachtio */
|
||||
const DRACHTIO_PORT = process.env.DRACHTIO_PORT;
|
||||
const DRACHTIO_HOST = process.env.DRACHTIO_HOST;
|
||||
const DRACHTIO_SECRET = process.env.DRACHTIO_SECRET;
|
||||
|
||||
/* freeswitch */
|
||||
const JAMBONES_API_BASE_URL = process.env.JAMBONES_API_BASE_URL;
|
||||
const JAMBONES_FREESWITCH = process.env.JAMBONES_FREESWITCH;
|
||||
const JAMBONES_FREESWITCH_MAX_CALL_DURATION_MINS = parseInt(process.env.JAMBONES_FREESWITCH_MAX_CALL_DURATION_MINS, 10)
|
||||
|| 180;
|
||||
|
||||
|
||||
const JAMBONES_SBCS = process.env.JAMBONES_SBCS;
|
||||
|
||||
/* websockets */
|
||||
const JAMBONES_WS_HANDSHAKE_TIMEOUT_MS = parseInt(process.env.JAMBONES_WS_HANDSHAKE_TIMEOUT_MS, 10) || 1500;
|
||||
const JAMBONES_WS_MAX_PAYLOAD = parseInt(process.env.JAMBONES_WS_MAX_PAYLOAD, 10) || 24 * 1024;
|
||||
const JAMBONES_WS_PING_INTERVAL_MS = parseInt(process.env.JAMBONES_WS_PING_INTERVAL_MS, 10) || 0;
|
||||
const MAX_RECONNECTS = 5;
|
||||
const RESPONSE_TIMEOUT_MS = parseInt(process.env.JAMBONES_WS_API_MSG_RESPONSE_TIMEOUT, 10) || 5000;
|
||||
|
||||
const JAMBONES_NETWORK_CIDR = process.env.JAMBONES_NETWORK_CIDR;
|
||||
const JAMBONES_TIME_SERIES_HOST = process.env.JAMBONES_TIME_SERIES_HOST;
|
||||
const JAMBONES_CLUSTER_ID = process.env.JAMBONES_CLUSTER_ID || 'default';
|
||||
const JAMBONES_ESL_LISTEN_ADDRESS = process.env.JAMBONES_ESL_LISTEN_ADDRESS;
|
||||
|
||||
/* tracing */
|
||||
const JAMBONES_OTEL_ENABLED = process.env.JAMBONES_OTEL_ENABLED;
|
||||
const JAMBONES_OTEL_SERVICE_NAME = process.env.JAMBONES_OTEL_SERVICE_NAME || 'jambonz-feature-server';
|
||||
const OTEL_EXPORTER_JAEGER_AGENT_HOST = process.env.OTEL_EXPORTER_JAEGER_AGENT_HOST;
|
||||
const OTEL_EXPORTER_JAEGER_ENDPOINT = process.env.OTEL_EXPORTER_JAEGER_ENDPOINT;
|
||||
const OTEL_EXPORTER_ZIPKIN_URL = process.env.OTEL_EXPORTER_ZIPKIN_URL;
|
||||
const OTEL_EXPORTER_COLLECTOR_URL = process.env.OTEL_EXPORTER_COLLECTOR_URL;
|
||||
|
||||
const JAMBONES_LOGLEVEL = process.env.JAMBONES_LOGLEVEL || 'info';
|
||||
const JAMBONES_INJECT_CONTENT = process.env.JAMBONES_INJECT_CONTENT;
|
||||
|
||||
const PORT = parseInt(process.env.HTTP_PORT, 10) || 3000;
|
||||
const HTTP_IP = process.env.HTTP_IP;
|
||||
const HTTP_PORT_MAX = parseInt(process.env.HTTP_PORT_MAX, 10);
|
||||
|
||||
const K8S = process.env.K8S;
|
||||
const K8S_SBC_SIP_SERVICE_NAME = process.env.K8S_SBC_SIP_SERVICE_NAME;
|
||||
|
||||
const JAMBONES_SUBNET = process.env.JAMBONES_SUBNET;
|
||||
|
||||
/* clean up */
|
||||
const JAMBONZ_CLEANUP_INTERVAL_MINS = process.env.JAMBONZ_CLEANUP_INTERVAL_MINS;
|
||||
const getCleanupIntervalMins = () => {
|
||||
const interval = parseInt(JAMBONZ_CLEANUP_INTERVAL_MINS, 10) || 60;
|
||||
return 1000 * 60 * interval;
|
||||
};
|
||||
|
||||
/* speech vendors */
|
||||
const AWS_REGION = process.env.AWS_REGION;
|
||||
const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID;
|
||||
const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY;
|
||||
const AWS_SNS_PORT = parseInt(process.env.AWS_SNS_PORT, 10) || 3001;
|
||||
const AWS_SNS_TOPIC_ARN = process.env.AWS_SNS_TOPIC_ARN;
|
||||
const AWS_SNS_PORT_MAX = parseInt(process.env.AWS_SNS_PORT_MAX, 10) || 3005;
|
||||
|
||||
const GCP_JSON_KEY = process.env.GCP_JSON_KEY;
|
||||
|
||||
const MICROSOFT_REGION = process.env.MICROSOFT_REGION;
|
||||
const MICROSOFT_API_KEY = process.env.MICROSOFT_API_KEY;
|
||||
|
||||
const SONIOX_API_KEY = process.env.SONIOX_API_KEY;
|
||||
|
||||
const DEEPGRAM_API_KEY = process.env.DEEPGRAM_API_KEY;
|
||||
|
||||
const ANCHOR_MEDIA_ALWAYS = process.env.ANCHOR_MEDIA_ALWAYS;
|
||||
const VMD_HINTS_FILE = process.env.VMD_HINTS_FILE;
|
||||
|
||||
const JAMBONES_AWS_TRANSCRIBE_USE_GRPC = process.env.JAMBONES_AWS_TRANSCRIBE_USE_GRPC;
|
||||
|
||||
/* security, secrets */
|
||||
const LEGACY_CRYPTO = !!process.env.LEGACY_CRYPTO;
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
const ENCRYPTION_SECRET = process.env.ENCRYPTION_SECRET;
|
||||
|
||||
/* HTTP/1 pool dispatcher */
|
||||
const HTTP_POOL = process.env.HTTP_POOL && parseInt(process.env.HTTP_POOL);
|
||||
const HTTP_POOLSIZE = parseInt(process.env.HTTP_POOLSIZE, 10) || 10;
|
||||
const HTTP_PIPELINING = parseInt(process.env.HTTP_PIPELINING, 10) || 1;
|
||||
const HTTP_TIMEOUT = 10000;
|
||||
const HTTP_PROXY_IP = process.env.JAMBONES_HTTP_PROXY_IP;
|
||||
const HTTP_PROXY_PORT = process.env.JAMBONES_HTTP_PROXY_PORT;
|
||||
const HTTP_PROXY_PROTOCOL = process.env.JAMBONES_HTTP_PROXY_PROTOCOL || 'http';
|
||||
const HTTP_USER_AGENT_HEADER = process.env.JAMBONES_HTTP_USER_AGENT_HEADER || 'jambonz';
|
||||
|
||||
const OPTIONS_PING_INTERVAL = parseInt(process.env.OPTIONS_PING_INTERVAL, 10) || 30000;
|
||||
|
||||
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 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;
|
||||
|
||||
const JAMBONES_USE_FREESWITCH_TIMER_FD = process.env.JAMBONES_USE_FREESWITCH_TIMER_FD;
|
||||
const JAMBONES_DIAL_SBC_FOR_REGISTERED_USER = process.env.JAMBONES_DIAL_SBC_FOR_REGISTERED_USER || false;
|
||||
const JAMBONES_MEDIA_TIMEOUT_MS = process.env.JAMBONES_MEDIA_TIMEOUT_MS || 0;
|
||||
const JAMBONES_MEDIA_HOLD_TIMEOUT_MS = process.env.JAMBONES_MEDIA_HOLD_TIMEOUT_MS || 0;
|
||||
// jambonz
|
||||
const JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS =
|
||||
process.env.JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS;
|
||||
|
||||
module.exports = {
|
||||
JAMBONES_MYSQL_HOST,
|
||||
JAMBONES_MYSQL_USER,
|
||||
JAMBONES_MYSQL_PASSWORD,
|
||||
JAMBONES_MYSQL_DATABASE,
|
||||
JAMBONES_MYSQL_REFRESH_TTL,
|
||||
JAMBONES_MYSQL_CONNECTION_LIMIT,
|
||||
JAMBONES_MYSQL_PORT,
|
||||
|
||||
DRACHTIO_PORT,
|
||||
DRACHTIO_HOST,
|
||||
DRACHTIO_SECRET,
|
||||
|
||||
JAMBONES_GATHER_EARLY_HINTS_MATCH,
|
||||
JAMBONZ_GATHER_EARLY_HINTS_MATCH,
|
||||
JAMBONES_GATHER_CLEAR_GLOBAL_HINTS_ON_EMPTY_HINTS,
|
||||
JAMBONES_FREESWITCH,
|
||||
SMPP_URL,
|
||||
JAMBONES_NETWORK_CIDR,
|
||||
JAMBONES_API_BASE_URL,
|
||||
JAMBONES_TIME_SERIES_HOST,
|
||||
JAMBONES_INJECT_CONTENT,
|
||||
JAMBONES_EAGERLY_PRE_CACHE_AUDIO,
|
||||
JAMBONES_ESL_LISTEN_ADDRESS,
|
||||
JAMBONES_SBCS,
|
||||
JAMBONES_OTEL_ENABLED,
|
||||
JAMBONES_OTEL_SERVICE_NAME,
|
||||
OTEL_EXPORTER_JAEGER_AGENT_HOST,
|
||||
OTEL_EXPORTER_JAEGER_ENDPOINT,
|
||||
OTEL_EXPORTER_ZIPKIN_URL,
|
||||
OTEL_EXPORTER_COLLECTOR_URL,
|
||||
|
||||
JAMBONES_LOGLEVEL,
|
||||
JAMBONES_CLUSTER_ID,
|
||||
PORT,
|
||||
HTTP_PORT_MAX,
|
||||
HTTP_IP,
|
||||
K8S,
|
||||
K8S_SBC_SIP_SERVICE_NAME,
|
||||
JAMBONES_SUBNET,
|
||||
NODE_ENV,
|
||||
JAMBONZ_CLEANUP_INTERVAL_MINS,
|
||||
getCleanupIntervalMins,
|
||||
checkEnvs,
|
||||
|
||||
AWS_REGION,
|
||||
AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY,
|
||||
AWS_SNS_PORT,
|
||||
AWS_SNS_TOPIC_ARN,
|
||||
AWS_SNS_PORT_MAX,
|
||||
|
||||
ANCHOR_MEDIA_ALWAYS,
|
||||
VMD_HINTS_FILE,
|
||||
JAMBONES_FREESWITCH_MAX_CALL_DURATION_MINS,
|
||||
JAMBONES_AWS_TRANSCRIBE_USE_GRPC,
|
||||
|
||||
LEGACY_CRYPTO,
|
||||
JWT_SECRET,
|
||||
ENCRYPTION_SECRET,
|
||||
HTTP_POOL,
|
||||
HTTP_POOLSIZE,
|
||||
HTTP_PIPELINING,
|
||||
HTTP_TIMEOUT,
|
||||
HTTP_PROXY_IP,
|
||||
HTTP_PROXY_PORT,
|
||||
HTTP_PROXY_PROTOCOL,
|
||||
HTTP_USER_AGENT_HEADER,
|
||||
OPTIONS_PING_INTERVAL,
|
||||
RESPONSE_TIMEOUT_MS,
|
||||
JAMBONES_WS_HANDSHAKE_TIMEOUT_MS,
|
||||
JAMBONES_WS_MAX_PAYLOAD,
|
||||
JAMBONES_WS_PING_INTERVAL_MS,
|
||||
MAX_RECONNECTS,
|
||||
GCP_JSON_KEY,
|
||||
MICROSOFT_REGION,
|
||||
MICROSOFT_API_KEY,
|
||||
SONIOX_API_KEY,
|
||||
DEEPGRAM_API_KEY,
|
||||
JAMBONZ_RECORD_WS_BASE_URL,
|
||||
JAMBONZ_RECORD_WS_USERNAME,
|
||||
JAMBONZ_RECORD_WS_PASSWORD,
|
||||
JAMBONZ_DISABLE_DIAL_PAI_HEADER,
|
||||
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
|
||||
};
|
||||
69
lib/dynamic-apps.js
Normal file
69
lib/dynamic-apps.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const appsMap = {
|
||||
queue: {
|
||||
// Dummy hook to follow later feature server logic.
|
||||
call_hook: {
|
||||
url: 'https://jambonz.org',
|
||||
method: 'GET'
|
||||
},
|
||||
account_sid: '',
|
||||
app_json: [{
|
||||
verb: 'dequeue',
|
||||
name: '',
|
||||
timeout: 5
|
||||
}]
|
||||
},
|
||||
user: {
|
||||
// Dummy hook to follow later feature server logic.
|
||||
call_hook: {
|
||||
url: 'https://jambonz.org',
|
||||
method: 'GET'
|
||||
},
|
||||
account_sid: '',
|
||||
app_json: [{
|
||||
verb: 'dial',
|
||||
callerId: '',
|
||||
answerOnBridge: true,
|
||||
target: [
|
||||
{
|
||||
type: 'user',
|
||||
name: ''
|
||||
}
|
||||
]
|
||||
}]
|
||||
},
|
||||
conference: {
|
||||
// Dummy hook to follow later feature server logic.
|
||||
call_hook: {
|
||||
url: 'https://jambonz.org',
|
||||
method: 'GET'
|
||||
},
|
||||
account_sid: '',
|
||||
app_json: [{
|
||||
verb: 'conference',
|
||||
name: '',
|
||||
beep: false,
|
||||
startConferenceOnEnter: true
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
const createJambonzApp = (type, {account_sid, name, caller_id}) => {
|
||||
const app = {...appsMap[type]};
|
||||
app.account_sid = account_sid;
|
||||
switch (type) {
|
||||
case 'queue':
|
||||
case 'conference':
|
||||
app.app_json[0].name = name;
|
||||
break;
|
||||
case 'user':
|
||||
app.app_json[0].callerId = caller_id;
|
||||
app.app_json[0].target[0].name = name;
|
||||
break;
|
||||
}
|
||||
app.app_json = JSON.stringify(app.app_json);
|
||||
return app;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createJambonzApp
|
||||
};
|
||||
41
lib/http-routes/api/conference.js
Normal file
41
lib/http-routes/api/conference.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const router = require('express').Router();
|
||||
const sysError = require('./error');
|
||||
const sessionTracker = require('../../session/session-tracker');
|
||||
const {TaskName} = require('../../utils/constants.json');
|
||||
const {DbErrorUnprocessableRequest} = require('../utils/errors');
|
||||
|
||||
/**
|
||||
* validate the call state
|
||||
*/
|
||||
function retrieveCallSession(callSid, opts) {
|
||||
const cs = sessionTracker.get(callSid);
|
||||
if (cs) {
|
||||
const task = cs.currentTask;
|
||||
if (!task || task.name != TaskName.Conference) {
|
||||
throw new DbErrorUnprocessableRequest(`conference api failure: indicated call is not waiting: ${task.name}`);
|
||||
}
|
||||
}
|
||||
return cs;
|
||||
}
|
||||
|
||||
/**
|
||||
* notify a waiting session that a conference has started
|
||||
*/
|
||||
router.post('/:callSid', async(req, res) => {
|
||||
const logger = req.app.locals.logger;
|
||||
const callSid = req.params.callSid;
|
||||
logger.debug({body: req.body}, 'got conference request');
|
||||
try {
|
||||
const cs = retrieveCallSession(callSid, req.body);
|
||||
if (!cs) {
|
||||
logger.info(`conference: callSid not found ${callSid}`);
|
||||
return res.sendStatus(404);
|
||||
}
|
||||
res.status(202).end();
|
||||
cs.notifyConferenceEvent(req.body);
|
||||
} catch (err) {
|
||||
sysError(logger, res, err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -3,146 +3,368 @@ 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 crypto = require('crypto');
|
||||
const SipError = require('drachtio-srf').SipError;
|
||||
const { validationResult, body } = require('express-validator');
|
||||
const { validate } = require('@jambonz/verb-specifications');
|
||||
const sysError = require('./error');
|
||||
const Requestor = require('../../utils/requestor');
|
||||
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 { decrypt } = require('../../utils/encrypt-decrypt');
|
||||
const { mergeSdpMedia, extractSdpMedia } = 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');
|
||||
|
||||
/**
|
||||
* Retrieve a connection to a drachtio server, lazily creating when first called
|
||||
*/
|
||||
function getSrfForOutdial(logger) {
|
||||
const {srf} = require('../../../');
|
||||
const {getSrf} = srf.locals;
|
||||
const srfForOutdial = getSrf();
|
||||
if (!srfForOutdial) throw new Error('no available feature servers for outbound call creation');
|
||||
return srfForOutdial;
|
||||
}
|
||||
const removeNullProperties = (obj) => (Object.keys(obj).forEach((key) => obj[key] === null && delete obj[key]), obj);
|
||||
const removeNulls = (req, res, next) => {
|
||||
req.body = removeNullProperties(req.body);
|
||||
next();
|
||||
};
|
||||
|
||||
router.post('/', async(req, res) => {
|
||||
const logger = req.app.locals.logger;
|
||||
logger.debug({body: req.body}, 'got createCall request');
|
||||
try {
|
||||
let uri, cs, to;
|
||||
const restDial = makeTask(logger, {'rest:dial': req.body});
|
||||
const srf = getSrfForOutdial(logger);
|
||||
const {getSBC, getFreeswitch} = srf.locals;
|
||||
const sbcAddress = getSBC();
|
||||
if (!sbcAddress) throw new Error('no available SBCs for outbound call creation');
|
||||
const target = restDial.to;
|
||||
const opts = { callingNumber: restDial.from };
|
||||
router.post('/',
|
||||
removeNulls,
|
||||
createCallSchema,
|
||||
body('tag').custom((value) => {
|
||||
if (value) {
|
||||
customSanitizeFunction(value);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
async(req, res) => {
|
||||
const {logger} = req.app.locals;
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
logger.info({errors: errors.array()}, 'POST /Calls: validation errors');
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
const accountSid = req.body.account_sid;
|
||||
const {srf} = require('../../..');
|
||||
|
||||
switch (target.type) {
|
||||
case 'phone':
|
||||
uri = `sip:${target.number}@${sbcAddress}`;
|
||||
to = target.number;
|
||||
break;
|
||||
case 'user':
|
||||
uri = `sip:${target.name}`;
|
||||
to = target.name;
|
||||
break;
|
||||
case 'sip':
|
||||
uri = target.sipUri;
|
||||
to = uri;
|
||||
break;
|
||||
const app_json = req.body['app_json'];
|
||||
try {
|
||||
// app_json is created only by api-server.
|
||||
if (app_json) {
|
||||
// if available, delete from req before creating task
|
||||
delete req.body.app_json;
|
||||
// validate possible app_json via verb-specifications
|
||||
validate(logger, JSON.parse(app_json));
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug({ err }, `invalid app_json: ${err.message}`);
|
||||
}
|
||||
|
||||
/* create endpoint for outdial */
|
||||
const ms = getFreeswitch();
|
||||
if (!ms) throw new Error('no available Freeswitch for outbound call creation');
|
||||
const ep = await ms.createEndpoint();
|
||||
logger.debug(`createCall: successfully allocated endpoint, sending INVITE to ${sbcAddress}`);
|
||||
logger.debug({body: req.body}, 'got createCall request');
|
||||
try {
|
||||
let uri, cs, to;
|
||||
|
||||
/* launch outdial */
|
||||
let sdp, sipLogger;
|
||||
const connectStream = async(remoteSdp) => {
|
||||
if (remoteSdp !== sdp) {
|
||||
ep.modify(sdp = remoteSdp);
|
||||
return true;
|
||||
const restDial = makeTask(logger, { 'rest:dial': req.body });
|
||||
restDial.appJson = app_json;
|
||||
|
||||
const {lookupAccountDetails, lookupCarrierByPhoneNumber, lookupCarrier} = dbUtils(logger, srf);
|
||||
const {
|
||||
lookupAppBySid
|
||||
} = srf.locals.dbHelpers;
|
||||
const {getSBC, getFreeswitch} = srf.locals;
|
||||
let sbcAddress = getSBC();
|
||||
if (!sbcAddress) throw new Error('no available SBCs for outbound call creation');
|
||||
const target = restDial.to;
|
||||
const opts = {
|
||||
callingNumber: restDial.from,
|
||||
...(restDial.callerName && {callingName: restDial.callerName}),
|
||||
headers: req.body.headers || {}
|
||||
};
|
||||
|
||||
|
||||
const {lookupTeamsByAccount, lookupAccountBySid} = srf.locals.dbHelpers;
|
||||
const account = await lookupAccountBySid(req.body.account_sid);
|
||||
const accountInfo = await lookupAccountDetails(req.body.account_sid);
|
||||
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';
|
||||
const rootSpan = new RootSpan('rest-call', {
|
||||
callSid,
|
||||
accountSid,
|
||||
...(req.body?.application_sid && {'X-Application-Sid': req.body.application_sid})
|
||||
});
|
||||
|
||||
opts.headers = {
|
||||
...opts.headers,
|
||||
'X-Jambonz-Routing': target.type,
|
||||
'X-Jambonz-FS-UUID': srf.locals.fsUUID,
|
||||
'X-Call-Sid': callSid,
|
||||
'X-Account-Sid': accountSid,
|
||||
'X-Trace-ID': rootSpan.traceId,
|
||||
...(req.body?.application_sid && {'X-Application-Sid': req.body.application_sid}),
|
||||
...(restDial.fromHost && {'X-Preferred-From-Host': restDial.fromHost}),
|
||||
...(record_all_calls && {'X-Record-All-Calls': recordOutputFormat}),
|
||||
...(target.proxy && {'X-SIP-Proxy': target.proxy}),
|
||||
...target.headers
|
||||
};
|
||||
|
||||
switch (target.type) {
|
||||
case 'phone':
|
||||
case 'teams':
|
||||
uri = `sip:${target.number}@${sbcAddress}`;
|
||||
to = target.number;
|
||||
if ('teams' === target.type) {
|
||||
const obj = await lookupTeamsByAccount(accountSid);
|
||||
if (!obj) throw new Error('dial to ms teams not allowed; account must first be configured with teams info');
|
||||
Object.assign(opts.headers, {
|
||||
'X-MS-Teams-FQDN': obj.ms_teams_fqdn,
|
||||
'X-MS-Teams-Tenant-FQDN': target.tenant || obj.tenant_fqdn
|
||||
});
|
||||
if (target.vmail === true) uri = `${uri};opaque=app:voicemail`;
|
||||
}
|
||||
break;
|
||||
case 'user':
|
||||
uri = `sip:${target.name}`;
|
||||
to = target.name;
|
||||
if (target.overrideTo) {
|
||||
Object.assign(opts.headers, {
|
||||
'X-Override-To': target.overrideTo
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'sip':
|
||||
uri = target.sipUri;
|
||||
to = uri;
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
Object.assign(opts, {
|
||||
proxy: `sip:${sbcAddress}`,
|
||||
localSdp: ep.local.sdp
|
||||
});
|
||||
if (target.auth) opts.auth = this.target.auth;
|
||||
|
||||
if (target.type === 'phone' && target.trunk) {
|
||||
const voip_carrier_sid = await lookupCarrier(req.body.account_sid, target.trunk);
|
||||
logger.info(
|
||||
`createCall: selected ${voip_carrier_sid} for requested carrier: ${target.trunk || 'unspecified'})`);
|
||||
if (voip_carrier_sid) {
|
||||
opts.headers['X-Requested-Carrier-Sid'] = voip_carrier_sid;
|
||||
}
|
||||
}
|
||||
|
||||
// find handling sbc sip for called user
|
||||
if (JAMBONES_DIAL_SBC_FOR_REGISTERED_USER && target.type === 'user') {
|
||||
const { registrar } = srf.locals.dbHelpers;
|
||||
const reg = await registrar.query(target.name);
|
||||
if (reg) {
|
||||
sbcAddress = selectHostPort(logger, reg.sbcAddress, 'tcp')[1];
|
||||
}
|
||||
//sbc outbound return 404 Notfound to handle case called user is not reigstered.
|
||||
}
|
||||
|
||||
/**
|
||||
* trunk isn't specified,
|
||||
* check if from-number matches any existing numbers on Jambonz
|
||||
* */
|
||||
if (target.type === 'phone' && !target.trunk) {
|
||||
const str = restDial.from || '';
|
||||
const callingNumber = str.startsWith('+') ? str.substring(1) : str;
|
||||
const voip_carrier_sid = await lookupCarrierByPhoneNumber(req.body.account_sid, callingNumber);
|
||||
logger.info(
|
||||
`createCall: selected ${voip_carrier_sid} for requested phone number: ${callingNumber || 'unspecified'})`);
|
||||
if (voip_carrier_sid) {
|
||||
opts.headers['X-Requested-Carrier-Sid'] = voip_carrier_sid;
|
||||
}
|
||||
}
|
||||
|
||||
/* create endpoint for outdial */
|
||||
const ms = getFreeswitch();
|
||||
if (!ms) throw new Error('no available Freeswitch for outbound call creation');
|
||||
const ep = await ms.createEndpoint();
|
||||
logger.debug(`createCall: successfully allocated endpoint, sending INVITE to ${sbcAddress}`);
|
||||
|
||||
/* launch outdial */
|
||||
let sdp, sipLogger;
|
||||
let dualEp;
|
||||
let localSdp = ep.local.sdp;
|
||||
|
||||
if (req.body.dual_streams) {
|
||||
dualEp = await ms.createEndpoint();
|
||||
localSdp = mergeSdpMedia(localSdp, dualEp.local.sdp);
|
||||
}
|
||||
|
||||
const connectStream = async(remoteSdp) => {
|
||||
if (remoteSdp !== sdp) {
|
||||
sdp = remoteSdp;
|
||||
if (req.body.dual_streams) {
|
||||
const [sdpLegA, sdpLebB] = extractSdpMedia(remoteSdp);
|
||||
|
||||
await ep.modify(sdpLegA);
|
||||
await dualEp.modify(sdpLebB);
|
||||
await ep.bridge(dualEp);
|
||||
} else {
|
||||
ep.modify(sdp);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
Object.assign(opts, {
|
||||
proxy: `sip:${sbcAddress}`,
|
||||
localSdp
|
||||
});
|
||||
if (target.auth) opts.auth = target.auth;
|
||||
|
||||
|
||||
/**
|
||||
/**
|
||||
* create our application object -
|
||||
* not from the database as per an inbound call,
|
||||
* but from the provided params in the request
|
||||
* we merge the inbound call application,
|
||||
* with the provided app params from the request body
|
||||
*/
|
||||
const app = req.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
|
||||
};
|
||||
|
||||
/**
|
||||
/**
|
||||
* attach our requestor and notifier objects
|
||||
* these will be used for all http requests we make during this call
|
||||
*/
|
||||
app.requestor = new Requestor(logger, app.call_hook);
|
||||
if (app.call_status_hook) app.notifier = new Requestor(logger, app.call_status_hook);
|
||||
else app.notifier = {request: () => {}};
|
||||
|
||||
/* now launch the outdial */
|
||||
try {
|
||||
const dlg = await srf.createUAC(uri, opts, {
|
||||
cbRequest: (err, inviteReq) => {
|
||||
if (err) {
|
||||
logger.error(err, 'createCall Error creating call');
|
||||
res.status(500).send('Call Failure');
|
||||
ep.destroy();
|
||||
}
|
||||
/* ok our outbound NVITE is in flight */
|
||||
|
||||
const tasks = [restDial];
|
||||
const callInfo = new CallInfo({
|
||||
direction: CallDirection.Outbound,
|
||||
req: inviteReq,
|
||||
to,
|
||||
tag: app.tag,
|
||||
accountSid: req.body.account_sid,
|
||||
applicationSid: app.application_sid
|
||||
});
|
||||
cs = new RestCallSession({logger, application: app, srf, req: inviteReq, ep, tasks, callInfo});
|
||||
cs.exec(req);
|
||||
|
||||
res.status(201).json({sid: cs.callSid});
|
||||
|
||||
sipLogger = logger.child({
|
||||
callSid: cs.callSid,
|
||||
callId: callInfo.callId
|
||||
});
|
||||
sipLogger.info(`outbound REST call attempt to ${JSON.stringify(target)} has been sent`);
|
||||
},
|
||||
cbProvisional: (prov) => {
|
||||
const callStatus = prov.body ? CallStatus.EarlyMedia : CallStatus.Ringing;
|
||||
if ([180, 183].includes(prov.status) && prov.body) connectStream(prov.body);
|
||||
restDial.emit('callStatus', prov.status, !!prov.body);
|
||||
cs.emit('callStatusChange', {callStatus, sipStatus: prov.status});
|
||||
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) {
|
||||
logger.debug('reusing websocket for call status hook');
|
||||
app.notifier = app.requestor;
|
||||
}
|
||||
});
|
||||
connectStream(dlg.remote.sdp);
|
||||
cs.emit('callStatusChange', {callStatus: CallStatus.InProgress, sipStatus: 200});
|
||||
restDial.emit('callStatus', 200);
|
||||
restDial.emit('connect', dlg);
|
||||
}
|
||||
catch (err) {
|
||||
let callStatus = CallStatus.Failed;
|
||||
if (err instanceof SipError) {
|
||||
if ([486, 603].includes(err.status)) callStatus = CallStatus.Busy;
|
||||
else if (487 === err.status) callStatus = CallStatus.NoAnswer;
|
||||
sipLogger.info(`REST outdial failed with ${err.status}`);
|
||||
cs.emit('callStatusChange', {callStatus, sipStatus: err.status});
|
||||
}
|
||||
else {
|
||||
cs.emit('callStatusChange', {callStatus, sipStatus: 500});
|
||||
sipLogger.error({err}, 'REST outdial failed');
|
||||
logger.debug({call_hook: app.call_hook}, 'creating http client for call hook');
|
||||
app.requestor = new HttpRequestor(logger, account.account_sid, app.call_hook, account.webhook_secret);
|
||||
}
|
||||
ep.destroy();
|
||||
if (!app.notifier && app.call_status_hook) {
|
||||
app.notifier = new HttpRequestor(logger, account.account_sid, app.call_status_hook, account.webhook_secret);
|
||||
logger.debug({call_status_hook: app.call_status_hook}, 'creating http client for call status hook');
|
||||
}
|
||||
else if (!app.notifier) {
|
||||
logger.debug('creating null call status hook');
|
||||
app.notifier = {request: () => {}, close: () => {}};
|
||||
}
|
||||
|
||||
/* now launch the outdial */
|
||||
try {
|
||||
const dlg = await srf.createUAC(uri, {...opts, followRedirects: true, keepUriOnRedirect: true}, {
|
||||
cbRequest: (err, inviteReq) => {
|
||||
/* in case of 302 redirect, this gets called twice, ignore the second
|
||||
except to update the req so that it can later be canceled if need be
|
||||
*/
|
||||
if (res.headersSent) {
|
||||
logger.info(`create-call: got redirect, updating request to new call-id ${req.get('Call-ID')}`);
|
||||
if (cs) cs.req = inviteReq;
|
||||
return;
|
||||
}
|
||||
|
||||
if (err) {
|
||||
logger.error(err, 'createCall Error creating call');
|
||||
res.status(500).send('Call Failure');
|
||||
return;
|
||||
}
|
||||
inviteReq.srf = srf;
|
||||
inviteReq.locals = {
|
||||
...(inviteReq || {}),
|
||||
callSid,
|
||||
application_sid: app.application_sid
|
||||
};
|
||||
/* ok our outbound INVITE is in flight */
|
||||
|
||||
const tasks = [restDial];
|
||||
sipLogger = logger.child({
|
||||
callSid,
|
||||
callId: inviteReq.get('Call-ID'),
|
||||
accountSid,
|
||||
traceId: rootSpan.traceId
|
||||
}, {
|
||||
...(account.enable_debug_log && {level: 'debug'})
|
||||
});
|
||||
app.requestor.logger = app.notifier.logger = sipLogger;
|
||||
const callInfo = new CallInfo({
|
||||
direction: CallDirection.Outbound,
|
||||
req: inviteReq,
|
||||
to,
|
||||
tag: app.tag,
|
||||
callSid,
|
||||
accountSid: req.body.account_sid,
|
||||
applicationSid: app.application_sid,
|
||||
traceId: rootSpan.traceId
|
||||
});
|
||||
cs = new RestCallSession({
|
||||
logger: sipLogger,
|
||||
application: app,
|
||||
srf,
|
||||
req: inviteReq,
|
||||
ep,
|
||||
ep2: dualEp,
|
||||
tasks,
|
||||
callInfo,
|
||||
accountInfo,
|
||||
rootSpan
|
||||
});
|
||||
cs.exec(req);
|
||||
|
||||
res.status(201).json({sid: cs.callSid, callId: inviteReq.get('Call-ID')});
|
||||
|
||||
sipLogger.info({sid: cs.callSid, callId: inviteReq.get('Call-ID')},
|
||||
`outbound REST call attempt to ${JSON.stringify(target)} has been sent`);
|
||||
},
|
||||
cbProvisional: (prov) => {
|
||||
const callStatus = prov.body ? CallStatus.EarlyMedia : CallStatus.Ringing;
|
||||
// Update call-id for sbc outbound INVITE
|
||||
cs.callInfo.sbcCallid = prov.get('X-CID');
|
||||
if ([180, 183].includes(prov.status) && prov.body) connectStream(prov.body);
|
||||
restDial.emit('callStatus', prov.status, !!prov.body);
|
||||
cs.emit('callStatusChange', {callStatus, sipStatus: prov.status});
|
||||
}
|
||||
});
|
||||
connectStream(dlg.remote.sdp);
|
||||
cs.emit('callStatusChange', {
|
||||
callStatus: CallStatus.InProgress,
|
||||
sipStatus: 200,
|
||||
sipReason: 'OK'
|
||||
});
|
||||
restDial.emit('callStatus', 200);
|
||||
restDial.emit('connect', dlg);
|
||||
}
|
||||
catch (err) {
|
||||
let callStatus = CallStatus.Failed;
|
||||
if (err instanceof SipError) {
|
||||
if ([486, 603].includes(err.status)) callStatus = CallStatus.Busy;
|
||||
else if (487 === err.status) callStatus = CallStatus.NoAnswer;
|
||||
if (sipLogger) sipLogger.info(`REST outdial failed with ${err.status}`);
|
||||
else console.log(`REST outdial failed with ${err.status}`);
|
||||
if (cs) cs.emit('callStatusChange', {
|
||||
callStatus,
|
||||
sipStatus: err.status,
|
||||
sipReason: err.reason
|
||||
});
|
||||
cs.callGone = true;
|
||||
}
|
||||
else {
|
||||
if (cs) cs.emit('callStatusChange', {
|
||||
callStatus,
|
||||
sipStatus: 500,
|
||||
sipReason: 'Internal Server Error'
|
||||
});
|
||||
if (sipLogger) sipLogger.error({err}, 'REST outdial failed');
|
||||
else console.error(err);
|
||||
}
|
||||
ep.destroy();
|
||||
if (dualEp) {
|
||||
dualEp.destroy();
|
||||
}
|
||||
setTimeout(restDial.kill.bind(restDial, cs), 5000);
|
||||
}
|
||||
} catch (err) {
|
||||
sysError(logger, res, err);
|
||||
}
|
||||
} catch (err) {
|
||||
sysError(logger, res, err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
38
lib/http-routes/api/create-message.js
Normal file
38
lib/http-routes/api/create-message.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const router = require('express').Router();
|
||||
const CallInfo = require('../../session/call-info');
|
||||
const {CallDirection} = require('../../utils/constants');
|
||||
const SmsSession = require('../../session/sms-call-session');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const makeTask = require('../../tasks/make_task');
|
||||
|
||||
router.post('/:sid', async(req, res) => {
|
||||
const {logger} = req.app.locals;
|
||||
const {srf} = req.app.locals;
|
||||
const {message_sid, account_sid} = req.body;
|
||||
|
||||
logger.debug({body: req.body}, 'got createMessage request');
|
||||
|
||||
const data = [{
|
||||
verb: 'message',
|
||||
...req.body
|
||||
}];
|
||||
delete data[0].message_sid;
|
||||
|
||||
try {
|
||||
const tasks = normalizeJambones(logger, data)
|
||||
.map((tdata) => makeTask(logger, tdata));
|
||||
|
||||
const callInfo = new CallInfo({
|
||||
direction: CallDirection.None,
|
||||
messageSid: message_sid,
|
||||
accountSid: account_sid,
|
||||
res
|
||||
});
|
||||
const cs = new SmsSession({logger, srf, tasks, callInfo});
|
||||
cs.exec();
|
||||
} catch (err) {
|
||||
logger.error({err, body: req.body}, 'OutboundSMS: error launching SmsCallSession');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
41
lib/http-routes/api/dequeue.js
Normal file
41
lib/http-routes/api/dequeue.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const router = require('express').Router();
|
||||
const sysError = require('./error');
|
||||
const sessionTracker = require('../../session/session-tracker');
|
||||
const {TaskName} = require('../../utils/constants.json');
|
||||
const {DbErrorUnprocessableRequest} = require('../utils/errors');
|
||||
|
||||
/**
|
||||
* validate the call state
|
||||
*/
|
||||
function retrieveCallSession(callSid, opts) {
|
||||
const cs = sessionTracker.get(callSid);
|
||||
if (cs) {
|
||||
const task = cs.currentTask;
|
||||
if (!task || task.name != TaskName.Dequeue) {
|
||||
throw new DbErrorUnprocessableRequest(`dequeue api failure: indicated call is not queued: ${task.name}`);
|
||||
}
|
||||
}
|
||||
return cs;
|
||||
}
|
||||
|
||||
/**
|
||||
* notify a session in a dequeue verb of an event
|
||||
*/
|
||||
router.post('/:callSid', async(req, res) => {
|
||||
const logger = req.app.locals.logger;
|
||||
const callSid = req.params.callSid;
|
||||
logger.debug({body: req.body}, 'got dequeue event');
|
||||
try {
|
||||
const cs = retrieveCallSession(callSid, req.body);
|
||||
if (!cs) {
|
||||
logger.info(`dequeue: callSid not found ${callSid}`);
|
||||
return res.sendStatus(404);
|
||||
}
|
||||
res.status(202).end();
|
||||
cs.notifyDequeueEvent(req.body);
|
||||
} catch (err) {
|
||||
sysError(logger, res, err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
43
lib/http-routes/api/enqueue.js
Normal file
43
lib/http-routes/api/enqueue.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const router = require('express').Router();
|
||||
const sysError = require('./error');
|
||||
const sessionTracker = require('../../session/session-tracker');
|
||||
const {TaskName} = require('../../utils/constants.json');
|
||||
const {DbErrorUnprocessableRequest} = require('../utils/errors');
|
||||
|
||||
/**
|
||||
* validate the call state
|
||||
*/
|
||||
function retrieveCallSession(logger, callSid, opts) {
|
||||
logger.debug(`retrieving session for callSid ${callSid}`);
|
||||
const cs = sessionTracker.get(callSid);
|
||||
if (cs) {
|
||||
const task = cs.currentTask;
|
||||
if (!task || task.name != TaskName.Enqueue) {
|
||||
logger.debug({cs}, 'found call session but not in Enqueue task??');
|
||||
throw new DbErrorUnprocessableRequest(`enqueue api failure: indicated call is not queued: ${task.name}`);
|
||||
}
|
||||
}
|
||||
return cs;
|
||||
}
|
||||
|
||||
/**
|
||||
* notify a waiting session that a queue event has occurred
|
||||
*/
|
||||
router.post('/:callSid', async(req, res) => {
|
||||
const logger = req.app.locals.logger;
|
||||
const callSid = req.params.callSid;
|
||||
logger.debug({callSid, body: req.body}, 'got enqueue event');
|
||||
try {
|
||||
const cs = retrieveCallSession(logger, callSid, req.body);
|
||||
if (!cs) {
|
||||
logger.info(`enqueue: callSid not found ${callSid}`);
|
||||
return res.sendStatus(404);
|
||||
}
|
||||
res.status(202).end();
|
||||
cs.notifyEnqueueEvent(req.body);
|
||||
} catch (err) {
|
||||
sysError(logger, res, err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,9 +2,11 @@ const api = require('express').Router();
|
||||
|
||||
api.use('/createCall', require('./create-call'));
|
||||
api.use('/updateCall', require('./update-call'));
|
||||
api.use('/conference', require('./conference'));
|
||||
api.use('/dequeue', require('./dequeue'));
|
||||
api.use('/enqueue', require('./enqueue'));
|
||||
|
||||
// health checks
|
||||
api.get('/', (req, res) => res.sendStatus(200));
|
||||
api.get('/health', (req, res) => res.sendStatus(200));
|
||||
api.use('/messaging', require('./messaging')); // inbound SMS
|
||||
api.use('/createMessage', require('./create-message')); // outbound SMS (REST)
|
||||
|
||||
module.exports = api;
|
||||
|
||||
86
lib/http-routes/api/messaging.js
Normal file
86
lib/http-routes/api/messaging.js
Normal file
@@ -0,0 +1,86 @@
|
||||
const router = require('express').Router();
|
||||
const HttpRequestor = require('../../utils/http-requestor');
|
||||
const WsRequestor = require('../../utils/ws-requestor');
|
||||
const CallInfo = require('../../session/call-info');
|
||||
const {CallDirection} = require('../../utils/constants');
|
||||
const SmsSession = require('../../session/sms-call-session');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const {TaskPreconditions} = require('../../utils/constants');
|
||||
const makeTask = require('../../tasks/make_task');
|
||||
|
||||
router.post('/:partner', async(req, res) => {
|
||||
const {logger} = req.app.locals;
|
||||
|
||||
logger.debug({body: req.body}, `got incomingSms request from partner ${req.params.partner}`);
|
||||
|
||||
let tasks;
|
||||
const {srf} = require('../../..');
|
||||
const {lookupAccountBySid} = srf.locals.dbHelpers;
|
||||
const app = req.body.app;
|
||||
const account = await lookupAccountBySid(app.accountSid);
|
||||
const hook = app.messaging_hook;
|
||||
let requestor;
|
||||
|
||||
if ('WS' === hook?.method) {
|
||||
app.requestor = new WsRequestor(logger, account.account_sid, hook, account.webhook_secret) ;
|
||||
app.notifier = app.requestor;
|
||||
}
|
||||
else {
|
||||
app.requestor = new HttpRequestor(logger, account.account_sid, hook, account.webhook_secret);
|
||||
app.notifier = {request: () => {}};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
carrier: req.params.partner,
|
||||
messageSid: app.messageSid,
|
||||
accountSid: app.accountSid,
|
||||
serviceProviderSid: account.service_provider_sid,
|
||||
applicationSid: app.applicationSid,
|
||||
from: req.body.from,
|
||||
to: req.body.to,
|
||||
cc: req.body.cc,
|
||||
text: req.body.text,
|
||||
media: req.body.media
|
||||
};
|
||||
res.status(200).json({sid: req.body.messageSid});
|
||||
|
||||
try {
|
||||
tasks = await requestor.request('session:new', hook, payload);
|
||||
logger.info({tasks}, 'response from incoming SMS webhook');
|
||||
} catch (err) {
|
||||
logger.error({err, hook}, 'Error sending incoming SMS message');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// process any verbs in response
|
||||
if (Array.isArray(tasks) && tasks.length) {
|
||||
const {srf} = req.app.locals;
|
||||
|
||||
app.requestor = requestor;
|
||||
app.notifier = {request: () => {}};
|
||||
|
||||
try {
|
||||
tasks = normalizeJambones(logger, tasks)
|
||||
.map((tdata) => makeTask(logger, tdata))
|
||||
.filter((t) => t.preconditions === TaskPreconditions.None);
|
||||
|
||||
if (0 === tasks.length) {
|
||||
logger.info('inboundSMS: after removing invalid verbs there are no tasks left to execute');
|
||||
return;
|
||||
}
|
||||
const callInfo = new CallInfo({
|
||||
direction: CallDirection.None,
|
||||
messageSid: app.messageSid,
|
||||
accountSid: app.accountSid,
|
||||
applicationSid: app.applicationSid
|
||||
});
|
||||
const cs = new SmsSession({logger, srf, application: app, tasks, callInfo});
|
||||
cs.exec();
|
||||
} catch (err) {
|
||||
logger.error({err, tasks}, 'InboundSMS: error launching SmsCallSession');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -9,22 +9,29 @@ const {CallStatus, CallDirection} = require('../../utils/constants');
|
||||
*/
|
||||
function retrieveCallSession(callSid, opts) {
|
||||
if (opts.call_status_hook && !opts.call_hook) {
|
||||
throw new DbErrorBadRequest('call_status_hook can be updated only when call_hook is also being updated');
|
||||
throw new DbErrorBadRequest(
|
||||
`call_status_hook can be updated only when call_hook is also being updated for call_sid ${callSid}`);
|
||||
}
|
||||
const cs = sessionTracker.get(callSid);
|
||||
if (!cs) {
|
||||
throw new DbErrorUnprocessableRequest(`call session is gone for call_sid ${callSid}`);
|
||||
}
|
||||
|
||||
if (opts.call_status === CallStatus.Completed && !cs.hasStableDialog) {
|
||||
throw new DbErrorUnprocessableRequest('current call state is incompatible with requested action');
|
||||
throw new DbErrorUnprocessableRequest(
|
||||
`current call state is incompatible with requested action for call_sid ${callSid}`);
|
||||
}
|
||||
else if (opts.call_status === CallStatus.NoAnswer) {
|
||||
if (cs.direction === CallDirection.Outbound) {
|
||||
if (!cs.isOutboundCallRinging) {
|
||||
throw new DbErrorUnprocessableRequest('current call state is incompatible with requested action');
|
||||
throw new DbErrorUnprocessableRequest(
|
||||
`current call state is incompatible with requested action for call_sid ${callSid}`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (cs.isInboundCallAnswered) {
|
||||
throw new DbErrorUnprocessableRequest('current call state is incompatible with requested action');
|
||||
throw new DbErrorUnprocessableRequest(
|
||||
`current call state is incompatible with requested action for call_sid ${callSid}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,15 +45,25 @@ function retrieveCallSession(callSid, opts) {
|
||||
router.post('/:callSid', async(req, res) => {
|
||||
const logger = req.app.locals.logger;
|
||||
const callSid = req.params.callSid;
|
||||
logger.debug({body: req.body}, 'got upateCall request');
|
||||
logger.debug({body: req.body}, 'got updateCall request');
|
||||
try {
|
||||
const cs = retrieveCallSession(callSid, req.body);
|
||||
if (!cs) {
|
||||
logger.info(`updateCall: callSid not found ${callSid}`);
|
||||
return res.sendStatus(404);
|
||||
}
|
||||
res.sendStatus(202);
|
||||
cs.updateCall(req.body, callSid);
|
||||
|
||||
if (req.body.sip_request) {
|
||||
const response = await cs.updateCall(req.body, callSid);
|
||||
res.status(200).json({
|
||||
status: response.status,
|
||||
reason: response.reason
|
||||
});
|
||||
}
|
||||
else {
|
||||
res.sendStatus(202);
|
||||
cs.updateCall(req.body, callSid);
|
||||
}
|
||||
} catch (err) {
|
||||
sysError(logger, res, err);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
const express = require('express');
|
||||
const api = require('./api');
|
||||
const routes = express.Router();
|
||||
const sessionTracker = require('../session/session-tracker');
|
||||
|
||||
const readiness = (req, res) => {
|
||||
const logger = req.app.locals.logger;
|
||||
const {count} = sessionTracker;
|
||||
const {srf} = require('../..');
|
||||
const {getFreeswitch} = srf.locals;
|
||||
if (getFreeswitch()) {
|
||||
return res.status(200).json({calls: count});
|
||||
}
|
||||
logger.info('responding to /health check with failure as freeswitch is not up');
|
||||
res.sendStatus(480);
|
||||
};
|
||||
|
||||
routes.use('/v1', api);
|
||||
|
||||
// health checks
|
||||
routes.get('/', (req, res) => {
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
routes.get('/health', (req, res) => {
|
||||
res.sendStatus(200);
|
||||
});
|
||||
// health check
|
||||
routes.get('/health', readiness);
|
||||
|
||||
module.exports = routes;
|
||||
|
||||
134
lib/http-routes/schemas/create-call.js
Normal file
134
lib/http-routes/schemas/create-call.js
Normal file
@@ -0,0 +1,134 @@
|
||||
const { checkSchema } = require('express-validator');
|
||||
|
||||
/**
|
||||
* @path api-server {{base_url}}/v1/Accounts/:account_sid/Calls
|
||||
* @see https://api.jambonz.org/#243a2edd-7999-41db-bd0d-08082bbab401
|
||||
*/
|
||||
const createCallSchema = checkSchema({
|
||||
application_sid: {
|
||||
isString: true,
|
||||
optional: true,
|
||||
isLength: { options: { min: 36, max: 36 } },
|
||||
errorMessage: 'Invalid application_sid',
|
||||
},
|
||||
answerOnBridge: {
|
||||
isBoolean: true,
|
||||
optional: true,
|
||||
errorMessage: 'Invalid answerOnBridge',
|
||||
},
|
||||
from: {
|
||||
errorMessage: 'Invalid from',
|
||||
isString: true,
|
||||
isLength: {
|
||||
options: { min: 1, max: 256 },
|
||||
},
|
||||
},
|
||||
fromHost: {
|
||||
isString: true,
|
||||
optional: true,
|
||||
errorMessage: 'Invalid fromHost',
|
||||
},
|
||||
to: {
|
||||
errorMessage: 'Invalid to',
|
||||
isObject: true,
|
||||
},
|
||||
callerName: {
|
||||
isString: true,
|
||||
optional: true,
|
||||
errorMessage: 'Invalid callerName',
|
||||
},
|
||||
amd: {
|
||||
isObject: true,
|
||||
optional: true,
|
||||
},
|
||||
tag: {
|
||||
isObject: true,
|
||||
optional: true,
|
||||
errorMessage: 'Invalid tag',
|
||||
},
|
||||
app_json: {
|
||||
isString: true,
|
||||
optional: true,
|
||||
errorMessage: 'Invalid app_json',
|
||||
},
|
||||
account_sid: {
|
||||
isString: true,
|
||||
optional: true,
|
||||
errorMessage: 'Invalid account_sid',
|
||||
isLength: { options: { min: 36, max: 36 } },
|
||||
},
|
||||
timeout: {
|
||||
isInt: true,
|
||||
optional: true,
|
||||
errorMessage: 'Invalid timeout',
|
||||
},
|
||||
timeLimit: {
|
||||
isInt: true,
|
||||
optional: true,
|
||||
errorMessage: 'Invalid timeLimit',
|
||||
},
|
||||
call_hook: {
|
||||
isObject: true,
|
||||
optional: true,
|
||||
errorMessage: 'Invalid call_hook',
|
||||
},
|
||||
call_status_hook: {
|
||||
isObject: true,
|
||||
optional: true,
|
||||
errorMessage: 'Invalid call_status_hook',
|
||||
},
|
||||
speech_synthesis_vendor: {
|
||||
isString: true,
|
||||
optional: true,
|
||||
errorMessage: 'Invalid speech_synthesis_vendor',
|
||||
},
|
||||
speech_synthesis_language: {
|
||||
isString: true,
|
||||
optional: true,
|
||||
errorMessage: 'Invalid speech_synthesis_language',
|
||||
},
|
||||
speech_synthesis_voice: {
|
||||
isString: true,
|
||||
optional: true,
|
||||
errorMessage: 'Invalid speech_synthesis_voice',
|
||||
},
|
||||
speech_recognizer_vendor: {
|
||||
isString: true,
|
||||
optional: true,
|
||||
errorMessage: 'Invalid speech_recognizer_vendor',
|
||||
},
|
||||
speech_recognizer_language: {
|
||||
isString: true,
|
||||
optional: true,
|
||||
errorMessage: 'Invalid speech_recognizer_language',
|
||||
}
|
||||
}, ['body']);
|
||||
|
||||
const customSanitizeFunction = (value) => {
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
value = value.map((item) => customSanitizeFunction(item));
|
||||
} else if (typeof value === 'object') {
|
||||
Object.keys(value).forEach((key) => {
|
||||
value[key] = customSanitizeFunction(value[key]);
|
||||
});
|
||||
} else if (typeof value === 'string') {
|
||||
/* trims characters at the beginning and at the end of a string */
|
||||
value = value.trim();
|
||||
|
||||
// Only attempt to parse if the whole string is a URL
|
||||
if (/^https?:\/\/\S+$/.test(value)) {
|
||||
value = new URL(value).toString();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
value = `Error: ${error.message}`;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createCallSchema,
|
||||
customSanitizeFunction
|
||||
};
|
||||
@@ -1,34 +1,224 @@
|
||||
const uuidv4 = require('uuid/v4');
|
||||
const {CallDirection} = require('./utils/constants');
|
||||
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');
|
||||
const Requestor = require('./utils/requestor');
|
||||
const HttpRequestor = require('./utils/http-requestor');
|
||||
const WsRequestor = require('./utils/ws-requestor');
|
||||
const makeTask = require('./tasks/make_task');
|
||||
const normalizeJamones = require('./utils/normalize-jamones');
|
||||
const parseUri = require('drachtio-srf').parseUri;
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const dbUtils = require('./utils/db-utils');
|
||||
const RootSpan = require('./utils/call-tracer');
|
||||
const listTaskNames = require('./utils/summarize-tasks');
|
||||
const {
|
||||
JAMBONES_MYSQL_REFRESH_TTL,
|
||||
JAMBONES_DISABLE_DIRECT_P2P_CALL
|
||||
} = require('./config');
|
||||
const { createJambonzApp } = require('./dynamic-apps');
|
||||
const { decrypt } = require('./utils/encrypt-decrypt');
|
||||
|
||||
module.exports = function(srf, logger) {
|
||||
const {lookupAppByPhoneNumber, lookupAppBySid, lookupAppByRealm} = srf.locals.dbHelpers;
|
||||
const {
|
||||
lookupAppByPhoneNumber,
|
||||
lookupAppByRegex,
|
||||
lookupAppBySid,
|
||||
lookupAppByRealm,
|
||||
lookupAppByTeamsTenant,
|
||||
registrar,
|
||||
lookupClientByAccountAndUsername
|
||||
} = srf.locals.dbHelpers;
|
||||
const {
|
||||
writeAlerts,
|
||||
AlertType
|
||||
} = srf.locals;
|
||||
const {lookupAccountDetails, lookupGoogleCustomVoice} = dbUtils(logger, srf);
|
||||
|
||||
function initLocals(req, res, next) {
|
||||
const callSid = uuidv4();
|
||||
req.locals = {
|
||||
callSid,
|
||||
logger: logger.child({callId: req.get('Call-ID'), callSid})
|
||||
};
|
||||
if (req.has('X-Application-Sid')) {
|
||||
async function initLocals(req, res, next) {
|
||||
const callId = req.get('Call-ID');
|
||||
const uri = parseUri(req.uri);
|
||||
logger.info({
|
||||
uri,
|
||||
callId,
|
||||
callingNumber: req.callingNumber,
|
||||
calledNumber: req.calledNumber
|
||||
}, 'new incoming call');
|
||||
if (!req.has('X-Account-Sid')) {
|
||||
logger.info('getAccountDetails - rejecting call due to missing X-Account-Sid header');
|
||||
return res.send(500);
|
||||
}
|
||||
const callSid = req.has('X-Retain-Call-Sid') ? req.get('X-Retain-Call-Sid') : crypto.randomUUID();
|
||||
const account_sid = req.get('X-Account-Sid');
|
||||
req.locals = {callSid, account_sid, callId};
|
||||
|
||||
let clientDb = null;
|
||||
if (req.has('X-Authenticated-User')) {
|
||||
req.locals.originatingUser = req.get('X-Authenticated-User');
|
||||
let clientSettings;
|
||||
const arr = /^(.*)@(.*)/.exec(req.locals.originatingUser);
|
||||
if (arr) {
|
||||
[clientSettings] = await lookupClientByAccountAndUsername(account_sid, arr[1]);
|
||||
}
|
||||
clientDb = await registrar.query(req.locals.originatingUser);
|
||||
clientDb = {
|
||||
...clientDb,
|
||||
...clientSettings,
|
||||
};
|
||||
}
|
||||
|
||||
// check for call to application
|
||||
if (uri.user?.startsWith('app-') && req.locals.originatingUser && clientDb?.allow_direct_app_calling) {
|
||||
const application_sid = uri.user.match(/app-(.*)/)[1];
|
||||
logger.debug(`got application from Request URI header: ${application_sid}`);
|
||||
req.locals.application_sid = application_sid;
|
||||
} else if (req.has('X-Application-Sid')) {
|
||||
const application_sid = req.get('X-Application-Sid');
|
||||
req.locals.logger.debug(`got application from X-Application-Sid header: ${application_sid}`);
|
||||
logger.debug(`got application from X-Application-Sid header: ${application_sid}`);
|
||||
req.locals.application_sid = application_sid;
|
||||
}
|
||||
if (req.has('X-Authenticated-User')) req.locals.originatingUser = req.get('X-Authenticated-User');
|
||||
// check for call to queue
|
||||
else if (uri.user?.startsWith('queue-') && req.locals.originatingUser && clientDb?.allow_direct_queue_calling) {
|
||||
const queue_name = uri.user.match(/queue-(.*)/)[1];
|
||||
logger.debug(`got Queue from Request URI header: ${queue_name}`);
|
||||
req.locals.queue_name = queue_name;
|
||||
}
|
||||
// check for call to conference
|
||||
else if (uri.user?.startsWith('conference-') && req.locals.originatingUser && clientDb?.allow_direct_app_calling) {
|
||||
const conference_id = uri.user.match(/conference-(.*)/)[1];
|
||||
logger.debug(`got Conference from Request URI header: ${conference_id}`);
|
||||
req.locals.conference_id = conference_id;
|
||||
}
|
||||
// check for call to registered user
|
||||
else if (!JAMBONES_DISABLE_DIRECT_P2P_CALL && req.locals.originatingUser && clientDb?.allow_direct_user_calling) {
|
||||
const arr = /^(.*)@(.*)/.exec(req.locals.originatingUser);
|
||||
if (arr) {
|
||||
const sipRealm = arr[2];
|
||||
const called_user = `${req.calledNumber}@${sipRealm}`;
|
||||
const reg = await registrar.query(called_user);
|
||||
if (reg) {
|
||||
logger.debug(`got called Number is a registered user: ${called_user}`);
|
||||
req.locals.called_user = called_user;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (req.has('X-MS-Teams-Tenant-FQDN')) req.locals.msTeamsTenant = req.get('X-MS-Teams-Tenant-FQDN');
|
||||
if (req.has('X-Cisco-Recording-Participant')) {
|
||||
const ciscoParticipants = req.get('X-Cisco-Recording-Participant');
|
||||
const regex = /sip:[a-zA-Z0-9]+@[a-zA-Z0-9.-_]+/g;
|
||||
const sipURIs = ciscoParticipants.match(regex);
|
||||
logger.info(`X-Cisco-Recording-Participant : ${sipURIs} `);
|
||||
if (sipURIs && sipURIs.length > 0) {
|
||||
req.locals.calledNumber = sipURIs[0];
|
||||
req.locals.callingNumber = sipURIs[1];
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
function createRootSpan(req, res, next) {
|
||||
const {callId, callSid, account_sid} = req.locals;
|
||||
const rootSpan = new RootSpan('incoming-call', req);
|
||||
const traceId = rootSpan.traceId;
|
||||
|
||||
req.locals = {
|
||||
...req.locals,
|
||||
traceId,
|
||||
logger: logger.child({
|
||||
callId,
|
||||
callSid,
|
||||
accountSid: account_sid,
|
||||
callingNumber: req.callingNumber,
|
||||
calledNumber: req.calledNumber,
|
||||
traceId}),
|
||||
rootSpan
|
||||
};
|
||||
|
||||
/**
|
||||
* end the span on final failure or cancel from caller;
|
||||
* otherwise it will be closed when sip dialog is destroyed
|
||||
*/
|
||||
req.once('cancel', () => {
|
||||
rootSpan.setAttributes({finalStatus: 487});
|
||||
rootSpan.end();
|
||||
});
|
||||
res.once('finish', () => {
|
||||
rootSpan.setAttributes({finalStatus: res.statusCode});
|
||||
res.statusCode >= 300 && rootSpan.end();
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
const handleSipRec = async(req, res, next) => {
|
||||
if (Array.isArray(req.payload) && req.payload.length > 1) {
|
||||
const {callId, logger} = req.locals;
|
||||
logger.debug({payload: req.payload}, 'handling siprec call');
|
||||
|
||||
try {
|
||||
const sdp = req.payload
|
||||
.find((p) => p.type === 'application/sdp')
|
||||
.content;
|
||||
const {sdp1, sdp2, ...metadata} = await parseSiprecPayload(req, logger);
|
||||
if (!req.locals.calledNumber && !req.locals.calledNumber) {
|
||||
req.locals.calledNumber = metadata.caller.number;
|
||||
req.locals.callingNumber = metadata.callee.number;
|
||||
}
|
||||
req.locals = {
|
||||
...req.locals,
|
||||
siprec: {
|
||||
metadata,
|
||||
sdp1,
|
||||
sdp2
|
||||
}
|
||||
};
|
||||
logger.info({callId, metadata, sdp}, 'successfully parsed SIPREC payload');
|
||||
} catch (err) {
|
||||
logger.info({err, callId}, 'Error parsing multipart payload');
|
||||
return res.send(503);
|
||||
}
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* retrieve account information for the incoming call
|
||||
*/
|
||||
async function getAccountDetails(req, res, next) {
|
||||
const {rootSpan, account_sid} = req.locals;
|
||||
|
||||
const {span} = rootSpan.startChildSpan('lookupAccountDetails');
|
||||
try {
|
||||
const accountDetail = await lookupAccountDetails(account_sid);
|
||||
const account = accountDetail?.account;
|
||||
req.locals.accountInfo = accountDetail;
|
||||
req.locals.service_provider_sid = account?.service_provider_sid;
|
||||
span.end();
|
||||
if (!account?.is_active) {
|
||||
logger.info(`Account is inactive or suspended ${account_sid}`);
|
||||
// TODO: alert
|
||||
return res.send(503, {headers: {'X-Reason': 'Account exists but is inactive'}});
|
||||
}
|
||||
// Change the default log level to debug
|
||||
if (account?.enable_debug_log) {
|
||||
req.locals.logger.level = 'debug';
|
||||
}
|
||||
logger.debug({accountInfo: req.locals?.accountInfo?.account}, `retrieved account info for ${account_sid}`);
|
||||
next();
|
||||
} catch (err) {
|
||||
span.end();
|
||||
logger.info({err}, `Error retrieving account details for account ${account_sid}`);
|
||||
res.send(503, {headers: {'X-Reason': `No Account exists for sid ${account_sid}`}});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Within the system, we deal with E.164 numbers _without_ the leading '+
|
||||
*/
|
||||
function normalizeNumbers(req, res, next) {
|
||||
const logger = req.locals.logger;
|
||||
const {logger, siprec} = req.locals;
|
||||
|
||||
if (siprec) return next();
|
||||
|
||||
Object.assign(req.locals, {
|
||||
calledNumber: req.calledNumber,
|
||||
callingNumber: req.callingNumber
|
||||
@@ -49,22 +239,68 @@ module.exports = function(srf, logger) {
|
||||
* Given the dialed DID/phone number, retrieve the application to invoke
|
||||
*/
|
||||
async function retrieveApplication(req, res, next) {
|
||||
const logger = req.locals.logger;
|
||||
const {logger, accountInfo, account_sid, rootSpan} = req.locals;
|
||||
const {span} = rootSpan.startChildSpan('lookupApplication');
|
||||
try {
|
||||
let app;
|
||||
if (req.locals.application_sid) app = await lookupAppBySid(req.locals.application_sid);
|
||||
else if (req.locals.originatingUser) {
|
||||
if (req.locals.queue_name) {
|
||||
logger.debug(`calling to queue ${req.locals.queue_name}, generating queue app`);
|
||||
app = createJambonzApp('queue', {account_sid, name: req.locals.queue_name});
|
||||
} else if (req.locals.called_user) {
|
||||
logger.debug(`calling to registered user ${req.locals.called_user}, generating dial app`);
|
||||
app = createJambonzApp('user',
|
||||
{account_sid, name: req.locals.called_user, caller_id: req.locals.callingNumber});
|
||||
} else if (req.locals.conference_id) {
|
||||
logger.debug(`calling to conference ${req.locals.conference_id}, generating conference app`);
|
||||
app = createJambonzApp('conference', {account_sid, name: req.locals.conference_id});
|
||||
} else if (req.locals.application_sid) {
|
||||
app = await lookupAppBySid(req.locals.application_sid);
|
||||
} else if (req.locals.originatingUser) {
|
||||
const arr = /^(.*)@(.*)/.exec(req.locals.originatingUser);
|
||||
if (arr) {
|
||||
const sipRealm = arr[2];
|
||||
logger.debug(`looking for device calling app for realm ${sipRealm}`);
|
||||
app = await lookupAppByRealm(sipRealm);
|
||||
if (app) logger.debug({app}, `retrieved device calling app for realm ${sipRealm}`);
|
||||
|
||||
if (app) {
|
||||
logger.debug({app}, `retrieved device calling app for realm ${sipRealm}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
else app = await lookupAppByPhoneNumber(req.locals.calledNumber);
|
||||
else if (req.locals.msTeamsTenant) {
|
||||
app = await lookupAppByTeamsTenant(req.locals.msTeamsTenant);
|
||||
if (app) logger.debug({app}, `retrieved app for ms teams tenant ${req.locals.msTeamsTenant}`);
|
||||
}
|
||||
else {
|
||||
const uri = parseUri(req.uri);
|
||||
const arr = /context-(.*)/.exec(uri?.user);
|
||||
if (arr) {
|
||||
// this is a transfer from another feature server
|
||||
const {retrieveKey, deleteKey} = srf.locals.dbHelpers;
|
||||
try {
|
||||
const obj = JSON.parse(await retrieveKey(arr[1]));
|
||||
logger.info({obj}, 'retrieved application and tasks for a transferred call from realtimedb');
|
||||
app = Object.assign(obj, {transferredCall: true});
|
||||
deleteKey(arr[1]).catch(() => {});
|
||||
} catch (err) {
|
||||
logger.error(err, `Error retrieving transferred call app for ${arr[1]}`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
const voip_carrier_sid = req.get('X-Voip-Carrier-Sid');
|
||||
app = await lookupAppByPhoneNumber(req.locals.calledNumber, voip_carrier_sid);
|
||||
|
||||
if (!app) {
|
||||
/* lookup by call_routes.regex */
|
||||
app = await lookupAppByRegex(req.locals.calledNumber, account_sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span.setAttributes({
|
||||
'app.hook': app?.call_hook?.url,
|
||||
'application_sid': req.locals.application_sid
|
||||
});
|
||||
span.end();
|
||||
if (!app || !app.call_hook || !app.call_hook.url) {
|
||||
logger.info(`rejecting call to ${req.locals.calledNumber}: no application or webhook url`);
|
||||
return res.send(480, {
|
||||
@@ -78,18 +314,66 @@ module.exports = function(srf, logger) {
|
||||
* create a requestor that we will use for all http requests we make during the call.
|
||||
* also create a notifier for call status events (if not needed, its a no-op).
|
||||
*/
|
||||
app.requestor = new Requestor(logger, app.call_hook);
|
||||
if (app.call_status_hook) app.notifier = new Requestor(logger, app.call_status_hook);
|
||||
else app.notifier = {request: () => {}};
|
||||
|
||||
req.locals.application = app;
|
||||
const obj = Object.assign({}, app);
|
||||
delete obj.requestor;
|
||||
delete obj.notifier;
|
||||
logger.info({app: obj}, `retrieved application for incoming call to ${req.locals.calledNumber}`);
|
||||
req.locals.callInfo = new CallInfo({req, app, direction: CallDirection.Inbound});
|
||||
/* allow for caching data - when caching treat retrieved data as immutable */
|
||||
const app2 = JAMBONES_MYSQL_REFRESH_TTL ? JSON.parse(JSON.stringify(app)) : app;
|
||||
if ('WS' === app.call_hook?.method ||
|
||||
app.call_hook?.url.startsWith('ws://') || app.call_hook?.url.startsWith('wss://')) {
|
||||
const requestor = new WsRequestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret) ;
|
||||
app2.requestor = requestor;
|
||||
app2.notifier = requestor;
|
||||
app2.call_hook.method = 'WS';
|
||||
}
|
||||
else {
|
||||
app2.requestor = new HttpRequestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret);
|
||||
if (app.call_status_hook) app2.notifier = new HttpRequestor(logger, account_sid, app.call_status_hook,
|
||||
accountInfo.account.webhook_secret);
|
||||
else app2.notifier = {request: () => {}, close: () => {}};
|
||||
}
|
||||
|
||||
// Resolve application.speech_synthesis_voice if it's custom voice
|
||||
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];
|
||||
const [custom_voice] = await lookupGoogleCustomVoice(google_custom_voice_sid);
|
||||
//google voice cloning key has size 200kb, jambonz should not resolve the voice here that the app's calling
|
||||
//webhook will receive big payload, tts-task should resolve the voice later.
|
||||
if (!custom_voice.use_voice_cloning_key) {
|
||||
app2.speech_synthesis_voice = {
|
||||
reportedUsage: custom_voice.reported_usage,
|
||||
model: custom_voice.model
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, env_vars, ...loggable} = appInfo;
|
||||
logger.info({app: loggable}, `retrieved application for incoming call to ${req.locals.calledNumber}`);
|
||||
req.locals.callInfo = new CallInfo({
|
||||
req,
|
||||
app: app2,
|
||||
direction: CallDirection.Inbound,
|
||||
traceId: rootSpan.traceId
|
||||
});
|
||||
// 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;
|
||||
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;
|
||||
delete app.callInfo;
|
||||
}
|
||||
next();
|
||||
} catch (err) {
|
||||
span.end();
|
||||
logger.error(err, `${req.get('Call-ID')} Error looking up application for ${req.calledNumber}`);
|
||||
res.send(500);
|
||||
}
|
||||
@@ -100,23 +384,103 @@ module.exports = function(srf, logger) {
|
||||
*/
|
||||
async function invokeWebCallback(req, res, next) {
|
||||
const logger = req.locals.logger;
|
||||
const app = req.locals.application;
|
||||
const {rootSpan, siprec, application:app} = req.locals;
|
||||
let span;
|
||||
try {
|
||||
if (app.tasks && app.tasks?.length > 0 && !JAMBONES_MYSQL_REFRESH_TTL) {
|
||||
app.tasks = normalizeJambones(logger, app.tasks).map((tdata) => makeTask(logger, tdata));
|
||||
if (0 === app.tasks.length) throw new Error('no application provided');
|
||||
return next();
|
||||
}
|
||||
/* retrieve the application to execute for this inbound call */
|
||||
const params = Object.assign(app.call_hook.method === 'POST' ? {sip: req.msg} : {},
|
||||
req.locals.callInfo);
|
||||
const json = await app.requestor.request(app.call_hook, params);
|
||||
app.tasks = normalizeJamones(logger, json).map((tdata) => makeTask(logger, tdata));
|
||||
let json;
|
||||
if (app.app_json) {
|
||||
json = JSON.parse(app.app_json);
|
||||
} else {
|
||||
const defaults = {
|
||||
synthesizer: {
|
||||
vendor: app.speech_synthesis_vendor,
|
||||
...(app.speech_synthesis_label && {label: app.speech_synthesis_label}),
|
||||
language: app.speech_synthesis_language,
|
||||
voice: app.speech_synthesis_voice,
|
||||
...(app.fallback_speech_synthesis_vendor && {fallback_vendor: app.fallback_speech_synthesis_vendor}),
|
||||
...(app.fallback_speech_synthesis_label && {fallback_label: app.fallback_speech_synthesis_label}),
|
||||
...(app.fallback_speech_synthesis_language && {fallback_language: app.fallback_speech_synthesis_language}),
|
||||
...(app.fallback_speech_synthesis_voice && {fallback_voice: app.fallback_speech_synthesis_voice})
|
||||
},
|
||||
recognizer: {
|
||||
vendor: app.speech_recognizer_vendor,
|
||||
...(app.speech_recognizer_label && {label: app.speech_recognizer_label}),
|
||||
language: app.speech_recognizer_language,
|
||||
...(app.fallback_speech_recognizer_vendor && {fallback_vendor: app.fallback_speech_recognizer_vendor}),
|
||||
...(app.fallback_speech_recognizer_label && {fallback_label: app.fallback_speech_recognizer_label}),
|
||||
...(app.fallback_speech_recognizer_language && {fallback_language: app.fallback_speech_recognizer_language})
|
||||
}
|
||||
};
|
||||
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 },
|
||||
{ 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, span);
|
||||
}
|
||||
|
||||
app.tasks = normalizeJambones(logger, json).map((tdata) => makeTask(logger, tdata));
|
||||
span?.setAttributes({
|
||||
'http.statusCode': 200,
|
||||
'app.tasks': listTaskNames(app.tasks)
|
||||
});
|
||||
span?.end();
|
||||
if (0 === app.tasks.length) throw new Error('no application provided');
|
||||
|
||||
if (siprec) {
|
||||
const tasks = app.tasks.filter((t) => AllowedSipRecVerbs.includes(t.name));
|
||||
if (0 === tasks.length) {
|
||||
logger.info({tasks: app.tasks}, 'no valid verbs in app found for an incoming siprec call');
|
||||
throw new Error('invalid verbs for incoming siprec call');
|
||||
}
|
||||
if (tasks.length < app.tasks.length) {
|
||||
logger.info('removing verbs that are not allowed for incoming siprec call');
|
||||
app.tasks = tasks;
|
||||
}
|
||||
}
|
||||
next();
|
||||
} catch (err) {
|
||||
logger.info(`Error retrieving or parsing application: ${err.message}`);
|
||||
res.send(480, {headers: {'X-Reason': err.message}});
|
||||
span?.setAttributes({webhookStatus: err.statusCode});
|
||||
span?.end();
|
||||
writeAlerts({
|
||||
account_sid: req.locals.account_sid,
|
||||
target_sid: req.locals.callSid,
|
||||
alert_type: AlertType.INVALID_APP_PAYLOAD,
|
||||
message: `${err?.message}`.trim()
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for parsing application'));
|
||||
logger.info({err}, `Error retrieving or parsing application: ${err?.message}`);
|
||||
res.send(480, {headers: {'X-Reason': err?.message || 'unknown'}});
|
||||
app.requestor.close(WS_CLOSE_CODES.GoingAway);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
initLocals,
|
||||
createRootSpan,
|
||||
handleSipRec,
|
||||
getAccountDetails,
|
||||
normalizeNumbers,
|
||||
retrieveApplication,
|
||||
invokeWebCallback
|
||||
|
||||
81
lib/session/adulting-call-session.js
Normal file
81
lib/session/adulting-call-session.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const CallSession = require('./call-session');
|
||||
const {CallStatus} = require('../utils/constants');
|
||||
const moment = require('moment');
|
||||
|
||||
/**
|
||||
* @classdesc Subclass of CallSession. Represents a CallSession
|
||||
* that was initially a child call leg; i.e. established via a Dial verb.
|
||||
* Now it is all grown up and filling out its own CallSession. Yoo-hoo!
|
||||
* @extends CallSession
|
||||
|
||||
*/
|
||||
class AdultingCallSession extends CallSession {
|
||||
constructor({logger, application, singleDialer, tasks, callInfo, accountInfo, rootSpan}) {
|
||||
super({
|
||||
logger,
|
||||
application,
|
||||
srf: singleDialer.dlg.srf,
|
||||
tasks,
|
||||
callInfo,
|
||||
accountInfo,
|
||||
rootSpan
|
||||
});
|
||||
this.sd = singleDialer;
|
||||
this.req = callInfo.req;
|
||||
|
||||
this.sd.dlg.on('destroy', () => {
|
||||
this.logger.info('AdultingCallSession: called party hung up');
|
||||
this._callReleased();
|
||||
});
|
||||
this.sd.emit('adulting');
|
||||
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
|
||||
}
|
||||
|
||||
get dlg() {
|
||||
return this.sd.dlg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: this is not an error. It is only here to avoid an assert ("no setter for dlg")
|
||||
* when there is a call in Session:_clearResources to null out dlg and ep
|
||||
*/
|
||||
set dlg(newDlg) {}
|
||||
|
||||
get ep() {
|
||||
return this.sd.ep;
|
||||
}
|
||||
|
||||
// When adulting session kicked from conference, replaceEndpoint is a must
|
||||
set ep(newEp) {
|
||||
this.sd.ep = newEp;
|
||||
}
|
||||
|
||||
get callSid() {
|
||||
return this.callInfo.callSid;
|
||||
}
|
||||
|
||||
_callerHungup() {
|
||||
this._hangup('caller');
|
||||
}
|
||||
|
||||
_jambonzHangup() {
|
||||
this._hangup();
|
||||
}
|
||||
|
||||
_hangup(terminatedBy = 'jambonz') {
|
||||
if (this.dlg.connectTime) {
|
||||
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
||||
this.rootSpan.setAttributes({'call.termination': `hangup by ${terminatedBy}`});
|
||||
this.callInfo.callTerminationBy = terminatedBy;
|
||||
this.emit('callStatusChange', {
|
||||
callStatus: CallStatus.Completed,
|
||||
duration
|
||||
});
|
||||
}
|
||||
this.logger.info(`InboundCallSession: ${terminatedBy} hung up`);
|
||||
this._callReleased();
|
||||
this.req.removeAllListeners('cancel');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AdultingCallSession;
|
||||
@@ -1,55 +1,103 @@
|
||||
const {CallDirection, CallStatus} = require('../utils/constants');
|
||||
const uuidv4 = require('uuid/v4');
|
||||
|
||||
const parseUri = require('drachtio-srf').parseUri;
|
||||
const crypto = require('crypto');
|
||||
const {JAMBONES_API_BASE_URL} = require('../config');
|
||||
/**
|
||||
* @classdesc Represents the common information for all calls
|
||||
* that is provided in call status webhooks
|
||||
*/
|
||||
class CallInfo {
|
||||
constructor(opts) {
|
||||
let from ;
|
||||
let srf;
|
||||
this.direction = opts.direction;
|
||||
this.traceId = opts.traceId;
|
||||
this.callTerminationBy = undefined;
|
||||
if (opts.req) {
|
||||
const u = opts.req.getParsedHeader('from');
|
||||
const uri = parseUri(u.uri);
|
||||
from = uri.user;
|
||||
this.callerName = u.name || '';
|
||||
}
|
||||
if (this.direction === CallDirection.Inbound) {
|
||||
// inbound call
|
||||
const {app, req} = opts;
|
||||
srf = req.srf;
|
||||
this.callSid = req.locals.callSid,
|
||||
this.accountSid = app.account_sid,
|
||||
this.applicationSid = app.application_sid;
|
||||
this.from = req.callingNumber;
|
||||
this.from = from || req.callingNumber;
|
||||
this.to = req.calledNumber;
|
||||
this.callerName = this.from.name || req.callingNumber;
|
||||
this.callId = req.get('Call-ID');
|
||||
this.sipStatus = 100;
|
||||
this.sipReason = 'Trying';
|
||||
this.callStatus = CallStatus.Trying;
|
||||
this.sbcCallid = req.get('X-CID');
|
||||
this.originatingSipIp = req.get('X-Forwarded-For');
|
||||
this.originatingSipTrunkName = req.get('X-Originating-Carrier');
|
||||
const {siprec} = req.locals;
|
||||
if (siprec) {
|
||||
const caller = parseUri(req.locals.callingNumber);
|
||||
const callee = parseUri(req.locals.calledNumber);
|
||||
this.participants = [
|
||||
{
|
||||
participant: 'caller',
|
||||
uriUser: caller?.user,
|
||||
uriHost: caller?.host
|
||||
},
|
||||
{
|
||||
participant: 'callee',
|
||||
uriUser: callee?.user,
|
||||
uriHost: callee?.host
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
else if (opts.parentCallInfo) {
|
||||
// outbound call that is a child of an existing call
|
||||
const {req, parentCallInfo, to, callSid} = opts;
|
||||
this.callSid = callSid || uuidv4();
|
||||
srf = req.srf;
|
||||
this.callSid = callSid || crypto.randomUUID();
|
||||
this.parentCallSid = parentCallInfo.callSid;
|
||||
this.accountSid = parentCallInfo.accountSid;
|
||||
this.applicationSid = parentCallInfo.applicationSid;
|
||||
this.from = req.callingNumber;
|
||||
this.from = from || req.callingNumber;
|
||||
this.to = to;
|
||||
this.callerId = this.from.name || req.callingNumber;
|
||||
this.callId = req.get('Call-ID');
|
||||
this.callStatus = CallStatus.Trying,
|
||||
this.sipStatus = 100;
|
||||
this.sipReason = 'Trying';
|
||||
}
|
||||
else if (this.direction === CallDirection.None) {
|
||||
// outbound SMS
|
||||
const {messageSid, accountSid, applicationSid, res} = opts;
|
||||
srf = res.srf;
|
||||
this.messageSid = messageSid;
|
||||
this.accountSid = accountSid;
|
||||
this.applicationSid = applicationSid;
|
||||
this.res = res;
|
||||
}
|
||||
else {
|
||||
// outbound call triggered by REST
|
||||
const {req, accountSid, applicationSid, to, tag} = opts;
|
||||
this.callSid = uuidv4();
|
||||
const {req, callSid, accountSid, applicationSid, to, tag} = opts;
|
||||
srf = req.srf;
|
||||
this.callSid = callSid;
|
||||
this.accountSid = accountSid;
|
||||
this.applicationSid = applicationSid;
|
||||
this.callStatus = CallStatus.Trying,
|
||||
this.callId = req.get('Call-ID');
|
||||
this.sipStatus = 100;
|
||||
this.from = req.callingNumber;
|
||||
this.sipReason = 'Trying';
|
||||
this.from = from || req.callingNumber;
|
||||
this.to = to;
|
||||
if (tag) this._customerData = tag;
|
||||
}
|
||||
|
||||
this.localSipAddress = srf.locals.localSipAddress;
|
||||
if (srf.locals.publicIp) {
|
||||
this.publicIp = srf.locals.publicIp;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,9 +105,10 @@ class CallInfo {
|
||||
* @param {string} callStatus - current call status
|
||||
* @param {number} sipStatus - current sip status
|
||||
*/
|
||||
updateCallStatus(callStatus, sipStatus) {
|
||||
updateCallStatus(callStatus, sipStatus, sipReason) {
|
||||
this.callStatus = callStatus;
|
||||
if (sipStatus) this.sipStatus = sipStatus;
|
||||
if (sipReason) this.sipReason = sipReason;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,13 +130,17 @@ class CallInfo {
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
callId: this.callId,
|
||||
sbcCallid: this.sbcCallid,
|
||||
sipStatus: this.sipStatus,
|
||||
sipReason: this.sipReason,
|
||||
callStatus: this.callStatus,
|
||||
callerId: this.callerId,
|
||||
accountSid: this.accountSid,
|
||||
applicationSid: this.applicationSid
|
||||
traceId: this.traceId,
|
||||
applicationSid: this.applicationSid,
|
||||
fsSipAddress: this.localSipAddress
|
||||
};
|
||||
['parentCallSid', 'originatingSipIp', 'originatingSipTrunkName'].forEach((prop) => {
|
||||
['parentCallSid', 'originatingSipIp', 'originatingSipTrunkName', 'callTerminationBy'].forEach((prop) => {
|
||||
if (this[prop]) obj[prop] = this[prop];
|
||||
});
|
||||
if (typeof this.duration === 'number') obj.duration = this.duration;
|
||||
@@ -95,6 +148,13 @@ class CallInfo {
|
||||
if (this._customerData) {
|
||||
Object.assign(obj, {customerData: this._customerData});
|
||||
}
|
||||
|
||||
if (JAMBONES_API_BASE_URL) {
|
||||
Object.assign(obj, {apiBaseUrl: JAMBONES_API_BASE_URL});
|
||||
}
|
||||
if (this.publicIp) {
|
||||
Object.assign(obj, {fsPublicIp: this.publicIp});
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,17 +8,22 @@ const CallSession = require('./call-session');
|
||||
|
||||
*/
|
||||
class ConfirmCallSession extends CallSession {
|
||||
constructor({logger, application, dlg, ep, tasks, callInfo}) {
|
||||
constructor({logger, application, dlg, ep, tasks, callInfo, accountInfo, memberId, confName, rootSpan, req}) {
|
||||
super({
|
||||
logger,
|
||||
application,
|
||||
srf: dlg.srf,
|
||||
callSid: dlg.callSid,
|
||||
tasks,
|
||||
callInfo
|
||||
callInfo,
|
||||
accountInfo,
|
||||
memberId,
|
||||
confName,
|
||||
rootSpan
|
||||
});
|
||||
this.dlg = dlg;
|
||||
this.ep = ep;
|
||||
this.req = req;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,6 +32,13 @@ class ConfirmCallSession extends CallSession {
|
||||
_clearResources() {
|
||||
}
|
||||
|
||||
_callerHungup() {
|
||||
}
|
||||
|
||||
_jambonzHangup() {
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
module.exports = ConfirmCallSession;
|
||||
|
||||
@@ -15,53 +15,87 @@ class InboundCallSession extends CallSession {
|
||||
srf: req.srf,
|
||||
application: req.locals.application,
|
||||
callInfo: req.locals.callInfo,
|
||||
tasks: req.locals.application.tasks
|
||||
accountInfo: req.locals.accountInfo,
|
||||
tasks: req.locals.application.tasks,
|
||||
rootSpan: req.locals.rootSpan
|
||||
});
|
||||
this.req = req;
|
||||
this.res = res;
|
||||
|
||||
req.on('cancel', this._callReleased.bind(this));
|
||||
req.once('cancel', this._onCancel.bind(this));
|
||||
|
||||
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
|
||||
this._notifyCallStatusChange({callStatus: CallStatus.Trying, sipStatus: 100});
|
||||
this._notifyCallStatusChange({
|
||||
callStatus: CallStatus.Trying,
|
||||
sipStatus: 100,
|
||||
sipReason: 'Trying'
|
||||
});
|
||||
}
|
||||
|
||||
_onCancel() {
|
||||
this.rootSpan.setAttributes({'call.termination': 'caller abandoned'});
|
||||
this.callInfo.callTerminationBy = 'caller';
|
||||
this._notifyCallStatusChange({
|
||||
callStatus: CallStatus.NoAnswer,
|
||||
sipStatus: 487,
|
||||
sipReason: 'Request Terminated'
|
||||
});
|
||||
this._callReleased();
|
||||
}
|
||||
|
||||
_onTasksDone() {
|
||||
if (!this.res.finalResponseSent) {
|
||||
this.logger.info('InboundCallSession:_onTasksDone auto-generating non-success response to invite');
|
||||
this.res.send(603);
|
||||
}
|
||||
else if (this.dlg && this.dlg.connected) {
|
||||
assert(this.dlg.connectTime);
|
||||
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
||||
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||
this.logger.debug('InboundCallSession:_onTasksDone hanging up call since all tasks are done');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer the call, if it has not already been answered.
|
||||
*/
|
||||
async propagateAnswer() {
|
||||
if (!this.dlg) {
|
||||
assert(this.ep);
|
||||
this.dlg = await this.srf.createUAS(this.req, this.res, {localSdp: this.ep.local.sdp});
|
||||
this.dlg.connectTime = moment();
|
||||
this.dlg.on('destroy', this._callerHungup.bind(this));
|
||||
this.emit('callStatusChange', {sipStatus: 200, callStatus: CallStatus.InProgress});
|
||||
this.logger.debug(`CallSession:propagateAnswer - answered callSid ${this.callSid}`);
|
||||
if (this._mediaServerFailure) {
|
||||
this.rootSpan.setAttributes({'call.termination': 'media server failure'});
|
||||
this.logger.info('InboundCallSession:_onTasksDone generating 480 due to media server failure');
|
||||
this.res.send(480, {
|
||||
headers: {
|
||||
'X-Reason': 'crankback: media server failure'
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
this.rootSpan.setAttributes({'call.termination': 'tasks completed without answering call'});
|
||||
this.logger.info('InboundCallSession:_onTasksDone auto-generating non-success response to invite');
|
||||
this.res.send(603);
|
||||
}
|
||||
}
|
||||
this.req.removeAllListeners('cancel');
|
||||
}
|
||||
|
||||
/**
|
||||
* This is invoked when the caller hangs up, in order to calculate the call duration.
|
||||
*/
|
||||
_callerHungup() {
|
||||
this._hangup('caller');
|
||||
}
|
||||
|
||||
_jambonzHangup(reason) {
|
||||
this.dlg?.destroy({
|
||||
headers: {
|
||||
...(reason && {'X-Reason': reason})
|
||||
}
|
||||
});
|
||||
// kill current task or wakeup the call session.
|
||||
this._callReleased();
|
||||
}
|
||||
|
||||
_hangup(terminatedBy = 'jambonz') {
|
||||
if (this.dlg === null) {
|
||||
this.logger.info('InboundCallSession:_hangup - race condition, dlg cleared by app hangup');
|
||||
return;
|
||||
}
|
||||
this.logger.info(`InboundCallSession: ${terminatedBy} hung up`);
|
||||
assert(this.dlg.connectTime);
|
||||
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
||||
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||
this.logger.debug('InboundCallSession: caller hung up');
|
||||
this.rootSpan.setAttributes({'call.termination': `hangup by ${terminatedBy}`});
|
||||
this.callInfo.callTerminationBy = terminatedBy;
|
||||
this.emit('callStatusChange', {
|
||||
callStatus: CallStatus.Completed,
|
||||
duration
|
||||
});
|
||||
this._callReleased();
|
||||
this.req.removeAllListeners('cancel');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,37 @@
|
||||
const CallSession = require('./call-session');
|
||||
const {CallStatus} = require('../utils/constants');
|
||||
const moment = require('moment');
|
||||
|
||||
/**
|
||||
* @classdesc Subclass of CallSession. This represents a CallSession that is
|
||||
* created for an outbound call that is initiated via the REST API.
|
||||
* @extends CallSession
|
||||
*/
|
||||
class RestCallSession extends CallSession {
|
||||
constructor({logger, application, srf, req, ep, tasks, callInfo}) {
|
||||
constructor({logger, application, srf, req, ep, ep2, tasks, callInfo, accountInfo, rootSpan}) {
|
||||
super({
|
||||
logger,
|
||||
application,
|
||||
srf,
|
||||
callSid: callInfo.callSid,
|
||||
tasks,
|
||||
callInfo
|
||||
callInfo,
|
||||
accountInfo,
|
||||
rootSpan
|
||||
});
|
||||
this.req = req;
|
||||
this.ep = ep;
|
||||
this.ep2 = ep2;
|
||||
// keep restDialTask reference for closing AMD
|
||||
if (tasks.length) {
|
||||
this.restDialTask = tasks[0];
|
||||
}
|
||||
|
||||
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
|
||||
this._notifyCallStatusChange({callStatus: CallStatus.Trying, sipStatus: 100});
|
||||
this._notifyCallStatusChange({
|
||||
callStatus: CallStatus.Trying,
|
||||
sipStatus: 100,
|
||||
sipReason: 'Trying'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,16 +41,29 @@ class RestCallSession extends CallSession {
|
||||
setDialog(dlg) {
|
||||
this.dlg = dlg;
|
||||
dlg.on('destroy', this._callerHungup.bind(this));
|
||||
dlg.connectTime = moment();
|
||||
dlg.on('refer', this._onRefer.bind(this));
|
||||
dlg.on('modify', this._onReinvite.bind(this));
|
||||
this.wrapDialog(dlg);
|
||||
}
|
||||
|
||||
/**
|
||||
* This is invoked when the called party hangs up, in order to calculate the call duration.
|
||||
*/
|
||||
_callerHungup() {
|
||||
this._hangup('caller');
|
||||
}
|
||||
|
||||
_jambonzHangup() {
|
||||
this._hangup();
|
||||
}
|
||||
|
||||
_hangup(terminatedBy = 'jambonz') {
|
||||
if (this.restDialTask) {
|
||||
this.restDialTask.turnOffAmd();
|
||||
}
|
||||
this.callInfo.callTerminationBy = terminatedBy;
|
||||
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
||||
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||
this.logger.debug('InboundCallSession: caller hung up');
|
||||
this.logger.info(`RestCallSession: called party hung up by ${terminatedBy}`);
|
||||
this._callReleased();
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ class SessionTracker extends Emitter {
|
||||
assert(callSid);
|
||||
this.sessions.delete(callSid);
|
||||
this.logger.info(`SessionTracker:remove callSid ${callSid}, we have ${this.sessions.size} being tracked`);
|
||||
if (0 === this.sessions.size) this.emit('idle');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
86
lib/session/siprec-call-session.js
Normal file
86
lib/session/siprec-call-session.js
Normal file
@@ -0,0 +1,86 @@
|
||||
const InboundCallSession = require('./inbound-call-session');
|
||||
const {createSipRecPayload} = require('../utils/siprec-utils');
|
||||
const {CallStatus} = require('../utils/constants');
|
||||
const {parseSiprecPayload} = require('../utils/siprec-utils');
|
||||
/**
|
||||
* @classdesc Subclass of InboundCallSession. This represents a CallSession that is
|
||||
* established for an inbound SIPREC call.
|
||||
* @extends InboundCallSession
|
||||
*/
|
||||
class SipRecCallSession extends InboundCallSession {
|
||||
constructor(req, res) {
|
||||
super(req, res);
|
||||
|
||||
const {sdp1, sdp2, metadata} = req.locals.siprec;
|
||||
this.sdp1 = sdp1;
|
||||
this.sdp2 = sdp2;
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
async _onReinvite(req, res) {
|
||||
try {
|
||||
this.logger.info(req.payload, 'SipRec Re-INVITE payload');
|
||||
const {sdp1: reSdp1, sdp2: reSdp2, metadata: reMetadata} = await parseSiprecPayload(req, this.logger);
|
||||
this.sdp1 = reSdp1;
|
||||
this.sdp2 = reSdp2;
|
||||
this.metadata = reMetadata;
|
||||
|
||||
if (this.ep && this.ep2) {
|
||||
let remoteSdp = this.sdp1.replace(/sendonly/, 'sendrecv');
|
||||
const newSdp1 = await this.ep.modify(remoteSdp);
|
||||
remoteSdp = this.sdp2.replace(/sendonly/, 'sendrecv');
|
||||
const newSdp2 = await this.ep2.modify(remoteSdp);
|
||||
const combinedSdp = await createSipRecPayload(newSdp1, newSdp2, this.logger);
|
||||
res.send(200, {body: combinedSdp});
|
||||
this.logger.info({offer: req.body, answer: combinedSdp}, 'SipRec handling reINVITE');
|
||||
}
|
||||
else {
|
||||
this.logger.info('got SipRec reINVITE but no endpoint and media has not been released');
|
||||
res.send(488);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'Error handling reinvite');
|
||||
}
|
||||
}
|
||||
|
||||
async answerSipRecCall() {
|
||||
try {
|
||||
this.ms = this.getMS();
|
||||
let remoteSdp = this.sdp1.replace(/sendonly/, 'sendrecv');
|
||||
this.ep = await this.ms.createEndpoint({remoteSdp});
|
||||
//this.logger.debug({remoteSdp, localSdp: this.ep.local.sdp}, 'SipRecCallSession - allocated first endpoint');
|
||||
remoteSdp = this.sdp2.replace(/sendonly/, 'sendrecv');
|
||||
this.ep2 = await this.ms.createEndpoint({remoteSdp});
|
||||
//this.logger.debug({remoteSdp, localSdp: this.ep2.local.sdp}, 'SipRecCallSession - allocated second endpoint');
|
||||
await this.ep.bridge(this.ep2);
|
||||
const combinedSdp = await createSipRecPayload(this.ep.local.sdp, this.ep2.local.sdp, this.logger);
|
||||
/*
|
||||
this.logger.debug({
|
||||
combinedSdp
|
||||
}, 'SipRecCallSession:_answerSipRecCall - created SIPREC payload');
|
||||
*/
|
||||
this.dlg = await this.srf.createUAS(this.req, this.res, {
|
||||
headers: {
|
||||
'Content-Type': 'application/sdp',
|
||||
'X-Trace-ID': this.req.locals.traceId,
|
||||
'X-Call-Sid': this.req.locals.callSid,
|
||||
...(this.applicationSid && {'X-Application-Sid': this.applicationSid})
|
||||
},
|
||||
localSdp: combinedSdp
|
||||
});
|
||||
this.dlg.on('destroy', this._callerHungup.bind(this));
|
||||
this.wrapDialog(this.dlg);
|
||||
this.dlg.callSid = this.callSid;
|
||||
this.emit('callStatusChange', {sipStatus: 200, sipReason: 'OK', callStatus: CallStatus.InProgress});
|
||||
|
||||
this.dlg.on('modify', this._onReinvite.bind(this));
|
||||
this.dlg.on('refer', this._onRefer.bind(this));
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'SipRecCallSession:_answerSipRecCall error:');
|
||||
if (this.res && !this.res.finalResponseSent) this.res.send(500);
|
||||
this._callReleased();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SipRecCallSession;
|
||||
22
lib/session/sms-call-session.js
Normal file
22
lib/session/sms-call-session.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const CallSession = require('./call-session');
|
||||
|
||||
/**
|
||||
* @classdesc Subclass of CallSession. Represents a CallSession
|
||||
* that is established for the purpose of sending an outbound SMS
|
||||
* @extends CallSession
|
||||
|
||||
*/
|
||||
class SmsCallSession extends CallSession {
|
||||
constructor({logger, application, srf, tasks, callInfo}) {
|
||||
super({
|
||||
logger,
|
||||
application,
|
||||
srf,
|
||||
tasks,
|
||||
callInfo
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = SmsCallSession;
|
||||
22
lib/tasks/answer.js
Normal file
22
lib/tasks/answer.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
|
||||
/**
|
||||
* Answer the call.
|
||||
* Note: This is rarely used, as the call is typically answered automatically when required by the app,
|
||||
* but it can be useful to force an answer before a pause in some cases
|
||||
*/
|
||||
class TaskAnswer extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
}
|
||||
|
||||
get name() { return TaskName.Answer; }
|
||||
|
||||
async exec(cs) {
|
||||
super.exec(cs);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskAnswer;
|
||||
889
lib/tasks/conference.js
Normal file
889
lib/tasks/conference.js
Normal file
@@ -0,0 +1,889 @@
|
||||
const Task = require('./task');
|
||||
const Emitter = require('events');
|
||||
const ConfirmCallSession = require('../session/confirm-call-session');
|
||||
const {TaskName, TaskPreconditions, BONG_TONE} = require('../utils/constants');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const makeTask = require('./make_task');
|
||||
const bent = require('bent');
|
||||
const assert = require('assert');
|
||||
const HttpRequestor = require('../utils/http-requestor');
|
||||
const WAIT = 'wait';
|
||||
const JOIN = 'join';
|
||||
const START = 'start';
|
||||
|
||||
|
||||
function confNoMatch(str) {
|
||||
return str.match(/^No active conferences/) || str.match(/Conference.*not found/);
|
||||
}
|
||||
function getWaitListName(confName) {
|
||||
return `${confName}:waitlist`;
|
||||
}
|
||||
|
||||
function camelize(str) {
|
||||
return str.replace(/(?:^\w|[A-Z]|\b\w)/g, function(word, index) {
|
||||
return index === 0 ? word.toLowerCase() : word.toUpperCase();
|
||||
})
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/-/g, '');
|
||||
}
|
||||
|
||||
function unhandled(logger, cs, evt) {
|
||||
this.participantCount = parseInt(evt.getHeader('Conference-Size'));
|
||||
// logger.debug({evt}, `unhandled conference event: ${evt.getHeader('Action')}`) ;
|
||||
logger.debug(`unhandled conference event: ${evt.getHeader('Action')}`) ;
|
||||
}
|
||||
|
||||
function capitalize(s) {
|
||||
if (typeof s !== 'string') return '';
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
class Conference extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.logger = logger;
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
if (!this.data.name) throw new Error('conference name required');
|
||||
|
||||
this.confName = this.data.name;
|
||||
[
|
||||
'beep', 'startConferenceOnEnter', 'endConferenceOnExit', 'joinMuted',
|
||||
'maxParticipants', 'waitHook', 'statusHook', 'endHook', 'enterHook', 'endConferenceDuration'
|
||||
].forEach((attr) => this[attr] = this.data[attr]);
|
||||
this.record = this.data.record || {};
|
||||
this.statusEvents = [];
|
||||
if (this.statusHook) {
|
||||
['start', 'end', 'join', 'leave', 'start-talking', 'stop-talking'].forEach((e) => {
|
||||
if ((this.data.statusEvents || []).includes(e)) this.statusEvents.push(e);
|
||||
});
|
||||
}
|
||||
|
||||
this.emitter = new Emitter();
|
||||
this.results = {};
|
||||
this.coaching = [];
|
||||
this.speakOnlyTo = this.data.speakOnlyTo;
|
||||
|
||||
// transferred from another server in order to bridge to a local caller?
|
||||
if (this.data._ && this.data._.connectTime) {
|
||||
this.connectTime = this.data._.connectTime;
|
||||
}
|
||||
}
|
||||
|
||||
get name() { return TaskName.Conference; }
|
||||
|
||||
get shouldRecord() { return this.record.path; }
|
||||
get isRecording() { return this.recordingInProgress; }
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
this.ep = ep;
|
||||
const dlg = cs.dlg;
|
||||
|
||||
// reset answer time if we were transferred from another feature server
|
||||
if (this.connectTime) dlg.connectTime = this.connectTime;
|
||||
|
||||
if (cs.sipRequestWithinDialogHook) {
|
||||
/* remove any existing listener to escape from duplicating events */
|
||||
this._removeSipIndialogRequestListener(this.dlg);
|
||||
this._initSipIndialogRequestListener(cs, dlg);
|
||||
}
|
||||
this.ep.on('destroy', this._kicked.bind(this, cs, dlg));
|
||||
|
||||
try {
|
||||
await this._init(cs, dlg);
|
||||
switch (this.action) {
|
||||
case JOIN:
|
||||
await this._doJoin(cs, dlg);
|
||||
break;
|
||||
case WAIT:
|
||||
await this._doWait(cs, dlg);
|
||||
break;
|
||||
case START:
|
||||
await this._doStart(cs, dlg);
|
||||
break;
|
||||
}
|
||||
await this.awaitTaskDone();
|
||||
|
||||
this.logger.debug(`Conference:exec - conference ${this.confName} is over`);
|
||||
if (this.callMoved !== false) await this.performAction(this.results);
|
||||
this._removeSipIndialogRequestListener(dlg);
|
||||
} catch (err) {
|
||||
this.logger.info(err, `TaskConference:exec - error in conference ${this.confName}`);
|
||||
}
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
this.logger.info(`Conference:kill ${this.confName}`);
|
||||
if (this._playSession) {
|
||||
this._playSession.kill();
|
||||
this._playSession = null;
|
||||
}
|
||||
this.emitter.emit('kill');
|
||||
await this._doFinalMemberCheck(cs);
|
||||
if (this.ep && this.ep.connected) {
|
||||
// drachtio-fsmrf override esl::event::CUSTOM to conference join listerner, After finish the conference
|
||||
// the application need to reset the esl::event::CUSTOM for another use on the same endpoint
|
||||
this.ep.resetEslCustomEvent();
|
||||
this.ep.api(`conference ${this.confName} kick ${this.memberId}`)
|
||||
.catch((err) => this.logger.info({err}, 'Error kicking participant'));
|
||||
}
|
||||
cs.clearConferenceDetails();
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which of three states we are in:
|
||||
* (1) Conference already exists -- we should JOIN
|
||||
* (2) Conference does not exist, and we should START it
|
||||
* (3) Conference does not exist, and we must WAIT for moderator
|
||||
* @param {CallSession} cs
|
||||
* @param {SipDialog} dlg
|
||||
*/
|
||||
async _init(cs, dlg) {
|
||||
const {createHash, retrieveHash} = cs.srf.locals.dbHelpers;
|
||||
this.friendlyName = this.confName;
|
||||
this.confName = `conf:${cs.accountSid}:${this.confName}`;
|
||||
|
||||
// check if conference is in progress
|
||||
const obj = await retrieveHash(this.confName);
|
||||
if (obj) {
|
||||
this.logger.info({obj}, `Conference:_init conference ${this.confName} is already started`);
|
||||
this.joinDetails = { conferenceSipAddress: obj.sipAddress};
|
||||
this.conferenceStartTime = new Date(parseInt(obj.startTime));
|
||||
this.statusEvents = obj.statusEvents ? JSON.parse(obj.statusEvents) : [];
|
||||
this.statusHook = obj.statusHook ? JSON.parse(obj.statusHook) : null;
|
||||
this.action = JOIN;
|
||||
}
|
||||
else {
|
||||
if (this.startConferenceOnEnter === false) {
|
||||
this.logger.info(`Conference:_init conference ${this.confName} does not exist, wait for moderator`);
|
||||
this.action = WAIT;
|
||||
}
|
||||
else {
|
||||
this.logger.info(`Conference:_init conference ${this.confName} does not exist, provision it now..`);
|
||||
const obj = {
|
||||
sipAddress: cs.srf.locals.localSipAddress,
|
||||
startTime: Date.now()
|
||||
};
|
||||
if (this.statusEvents.length > 0 && this.statusHook) {
|
||||
Object.assign(obj, {
|
||||
statusEvents: JSON.stringify(this.statusEvents),
|
||||
statusHook: JSON.stringify(this._normalizeHook(cs, this.statusHook))
|
||||
});
|
||||
}
|
||||
const added = await createHash(this.confName, obj);
|
||||
if (added) {
|
||||
this.logger.info(`Conference:_init conference ${this.confName} successfully provisioned`);
|
||||
this.conferenceStartTime = new Date(obj.startTime);
|
||||
this.action = START;
|
||||
}
|
||||
else {
|
||||
this.logger.info(`Conference:_init conference ${this.confName} provision failed..someone beat me to it?`);
|
||||
const obj = await retrieveHash(this.confName);
|
||||
if (null === obj) {
|
||||
this.logger.error(`Conference:_init conference ${this.confName} provision failed again...exiting`);
|
||||
throw new Error('Failed to join conference');
|
||||
}
|
||||
this.joinDetails = { conferenceSipAddress: obj.sipAddress};
|
||||
this.conferenceStartTime = new Date(obj.startTime);
|
||||
this.statusEvents = obj.statusEvents ? JSON.parse(obj.statusEvents) : [];
|
||||
this.statusHook = obj.statusHook ? JSON.parse(obj.statusHook) : null;
|
||||
this.action = JOIN;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for entry to a conference, which means
|
||||
* - add ourselves to the waiting list for the conference,
|
||||
* - if provided, continually invoke waitHook to play or say something (pause allowed as well)
|
||||
* - wait for an event indicating the conference has started (or caller hangs up).
|
||||
*
|
||||
* Returns a Promise that is resolved when:
|
||||
* a. caller hangs up while waiting, or
|
||||
* b. conference starts, participant joins the conference
|
||||
* @param {CallSession} cs
|
||||
* @param {SipDialog} dlg
|
||||
*/
|
||||
async _doWait(cs, dlg) {
|
||||
await this._addToWaitList(cs);
|
||||
|
||||
return new Promise(async(resolve, reject) => {
|
||||
this.emitter
|
||||
.once('join', (opts) => {
|
||||
this.joinDetails = opts;
|
||||
this.logger.info({opts}, `time to join conference ${this.confName}`);
|
||||
if (this._playSession) {
|
||||
this._playSession.kill();
|
||||
this._playSession = null;
|
||||
}
|
||||
|
||||
// return a Promise that resolves at the end of the conference for this caller
|
||||
this.emitter.removeAllListeners();
|
||||
resolve(this._doJoin(cs, dlg));
|
||||
})
|
||||
.once('kill', () => {
|
||||
this._removeFromWaitList(cs);
|
||||
if (this._playSession) {
|
||||
this.logger.debug('killing waitUrl');
|
||||
this._playSession.kill();
|
||||
this._playSession = null;
|
||||
}
|
||||
cs.clearConferenceDetails();
|
||||
resolve();
|
||||
});
|
||||
|
||||
if (this.waitHook) {
|
||||
do {
|
||||
try {
|
||||
await this.ep.play('silence_stream://750');
|
||||
const tasks = await this._playHook(cs, dlg, this.waitHook);
|
||||
if (0 === tasks.length) break;
|
||||
} catch (err) {
|
||||
if (!this.joinDetails && !this.killed) {
|
||||
this.logger.info(err, `Conference:_doWait: failed retrieving waitHook for ${this.confName}`);
|
||||
}
|
||||
this._playSession = null;
|
||||
break;
|
||||
}
|
||||
} while (!this.killed && !this.joinDetails);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a conference that has already been started.
|
||||
* The conference may be homed on this feature server, or another one -
|
||||
* in the latter case, move the call to the other server via REFER
|
||||
* @param {CallSession} cs
|
||||
* @param {SipDialog} dlg
|
||||
*/
|
||||
async _doJoin(cs, dlg) {
|
||||
assert(this.joinDetails.conferenceSipAddress);
|
||||
if (cs.srf.locals.localSipAddress !== this.joinDetails.conferenceSipAddress && !cs.isTransferredCall) {
|
||||
this.logger.info({
|
||||
localServer: cs.srf.locals.localSipAddress,
|
||||
confServer: this.joinDetails.conferenceSipAddress
|
||||
}, `Conference:_doJoin: conference ${this.confName} is hosted elsewhere`);
|
||||
const success = await this.transferCallToFeatureServer(cs, this.joinDetails.conferenceSipAddress, {
|
||||
connectTime: dlg.connectTime.valueOf()
|
||||
});
|
||||
|
||||
/**
|
||||
* If the REFER succeeded, we will get a BYE from the SBC
|
||||
* which will trigger kill and the end of the execution of the CallSession
|
||||
* which is what we want - so do nothing and let that happen.
|
||||
* If on the other hand, the REFER failed then we are in a bad state
|
||||
* and need to end the conference task with a failure indication and
|
||||
* allow the application to continue on
|
||||
*/
|
||||
if (success) {
|
||||
this.logger.info(`Conference:_doJoin: REFER of ${this.confName} succeeded`);
|
||||
return;
|
||||
}
|
||||
this.notifyTaskDone();
|
||||
return;
|
||||
}
|
||||
this.logger.info(`Conference:_doJoin: conference ${this.confName} is hosted locally`);
|
||||
await this._joinConference(cs, dlg, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a conference and notify anyone on the waiting list
|
||||
* @param {CallSession} cs
|
||||
* @param {SipDialog} dlg
|
||||
*/
|
||||
async _doStart(cs, dlg) {
|
||||
await this._joinConference(cs, dlg, true);
|
||||
|
||||
// notify waiting list members
|
||||
try {
|
||||
const {retrieveSet, deleteKey} = cs.srf.locals.dbHelpers;
|
||||
const setName = getWaitListName(this.confName);
|
||||
const members = await retrieveSet(setName);
|
||||
if (Array.isArray(members) && members.length > 0) {
|
||||
this.logger.info({members}, `Conference:doStart - notifying waiting list for ${this.confName}`);
|
||||
for (const url of members) {
|
||||
try {
|
||||
await bent('POST', 202)(url, {event: 'start', conferenceSipAddress: cs.srf.locals.localSipAddress});
|
||||
} catch (err) {
|
||||
this.logger.info(err, `Failed notifying ${url} to join ${this.confName}`);
|
||||
}
|
||||
}
|
||||
// now clear the waiting list
|
||||
deleteKey(setName);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'Conference:_doStart - error notifying wait list');
|
||||
}
|
||||
}
|
||||
|
||||
async _joinConference(cs, dlg, startConf) {
|
||||
if (startConf) {
|
||||
// conference should not exist - check but continue in either case
|
||||
const result = await cs.getMS().api(`conference ${this.confName} list count`);
|
||||
const notFound = typeof result === 'string' && confNoMatch(result);
|
||||
if (!notFound) {
|
||||
this.logger.info({result},
|
||||
`Conference:_joinConference: asked to start ${this.confName} but it unexpectedly exists`);
|
||||
}
|
||||
else {
|
||||
this.participantCount = 0;
|
||||
}
|
||||
this._notifyConferenceEvent(cs, 'start');
|
||||
}
|
||||
|
||||
if (this.enterHook) {
|
||||
try {
|
||||
await this._playHook(cs, dlg, this.enterHook);
|
||||
if (!dlg.connected) {
|
||||
this.logger.debug('Conference:_doJoin: caller hung up during entry prompt');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err, `Error playing enterHook to caller for conference ${this.confName}`);
|
||||
}
|
||||
}
|
||||
|
||||
const opts = {};
|
||||
if (this.endConferenceOnExit || this.startConferenceOnEnter || this.joinMuted) {
|
||||
Object.assign(opts, {flags: {
|
||||
...(this.endConferenceOnExit && {endconf: true}),
|
||||
...(this.startConferenceOnEnter && {moderator: true}),
|
||||
//https://developer.signalwire.com/freeswitch/FreeSWITCH-Explained/Modules/mod_conference_3965534/
|
||||
// mute | Enter conference muted
|
||||
...((this.joinMuted || this.speakOnlyTo) && {mute: true}),
|
||||
}});
|
||||
|
||||
/**
|
||||
* Note on the above: if we are joining in "coaching" mode (ie only going to heard by a subset of participants)
|
||||
* then we join muted temporarily, and then unmute ourselves once we have identified the subset of participants
|
||||
* to whom we will be speaking.
|
||||
*/
|
||||
}
|
||||
|
||||
try {
|
||||
const {memberId, confUuid} = await this.ep.join(this.confName, opts);
|
||||
this.logger.debug({memberId, confUuid}, `Conference:_joinConference: successfully joined ${this.confName}`);
|
||||
this.memberId = parseInt(memberId, 10);
|
||||
this.confUuid = confUuid;
|
||||
|
||||
// set a tag for this member, if provided
|
||||
if (this.data.memberTag) {
|
||||
this.setMemberTag(this.data.memberTag);
|
||||
}
|
||||
|
||||
cs.setConferenceDetails(memberId, this.confName, confUuid);
|
||||
const response = await this.ep.api('conference', [this.confName, 'get', 'count']);
|
||||
if (response.body && /\d+/.test(response.body)) this.participantCount = parseInt(response.body);
|
||||
this._notifyConferenceEvent(cs, 'join');
|
||||
|
||||
// start recording if requested and we just started the conference
|
||||
if (startConf && this.shouldRecord) {
|
||||
this.logger.info(`recording conference to ${this.record.path}`);
|
||||
try {
|
||||
await this.ep.api(`conference ${this.confName} record ${this.record.path}`);
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Conference:_joinConference - failed to start recording');
|
||||
}
|
||||
}
|
||||
|
||||
// listen for conference events
|
||||
this.ep.filter('Conference-Unique-ID', this.confUuid);
|
||||
this.ep.conn.on('esl::event::CUSTOM::*', this.__onConferenceEvent.bind(this, cs)) ;
|
||||
|
||||
// optionally play beep to conference on entry
|
||||
if (this.beep === true) {
|
||||
this.ep.api('conference',
|
||||
[this.confName, 'play', BONG_TONE])
|
||||
.catch((err) => {});
|
||||
}
|
||||
|
||||
if (this.speakOnlyTo) {
|
||||
this.setCoachMode(this.speakOnlyTo);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err, `Failed to join conference ${this.confName}`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (typeof this.maxParticipants === 'number' && this.maxParticipants > 1) {
|
||||
this.ep.api('conference', `${this.confName} set max_members ${this.maxParticipants}`)
|
||||
.catch((err) => this.logger.error(err, `Error setting max participants to ${this.maxParticipants}`));
|
||||
}
|
||||
|
||||
if (typeof this.endConferenceDuration === 'number' && this.endConferenceDuration >= 0) {
|
||||
this.ep.api('conference', `${this.confName} set endconference_grace_time ${this.endConferenceDuration}`)
|
||||
.catch((err) => this.logger.error(err, `Error setting end conference time to ${this.endConferenceDuration}`));
|
||||
}
|
||||
}
|
||||
|
||||
_initSipIndialogRequestListener(cs, dlg) {
|
||||
dlg.on('info', this._onRequestWithinDialog.bind(this, cs));
|
||||
dlg.on('message', this._onRequestWithinDialog.bind(this, cs));
|
||||
}
|
||||
|
||||
_removeSipIndialogRequestListener(dlg) {
|
||||
dlg && dlg.removeAllListeners('message');
|
||||
dlg && dlg.removeAllListeners('info');
|
||||
}
|
||||
|
||||
_onRequestWithinDialog(cs, req, res) {
|
||||
cs._onRequestWithinDialog(req, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* The conference we have been waiting for has started.
|
||||
* It may be on this server or a different one, and we are
|
||||
* given instructions how to find it and connect.
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.confName name of the conference
|
||||
* @param {string} opts.conferenceSipAddress ip:port of the feature server hosting the conference
|
||||
*/
|
||||
notifyStartConference(cs, opts) {
|
||||
this.logger.info({opts}, `Conference:notifyStartConference: conference ${this.confName} has now started`);
|
||||
this.conferenceStartTime = new Date();
|
||||
this.emitter.emit('join', opts);
|
||||
}
|
||||
|
||||
async doConferenceMuteNonModerators(cs, opts) {
|
||||
const mute = opts.conf_mute_status === 'mute';
|
||||
assert (cs.isInConference);
|
||||
|
||||
this.logger.info(`Conference:doConferenceMuteNonModerators ${mute ? 'muting' : 'unmuting'} non-moderators`);
|
||||
this.ep.api(`conference ${this.confName} ${mute ? 'mute' : 'unmute'} non_moderator`)
|
||||
.catch((err) => this.logger.info({err}, 'Error muting or unmuting non_moderators'));
|
||||
|
||||
if (this.conf_hold_status !== 'hold' && this._playSession) {
|
||||
this._playSession.kill();
|
||||
this._playSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
doConferenceMute(cs, opts) {
|
||||
assert (cs.isInConference);
|
||||
|
||||
const mute = opts.conf_mute_status === 'mute';
|
||||
this.ep.api(`conference ${this.confName} ${mute ? 'mute' : 'unmute'} ${this.memberId}`)
|
||||
.catch((err) => this.logger.info({err}, 'Error muting or unmuting participant'));
|
||||
}
|
||||
|
||||
doConferenceHold(cs, opts) {
|
||||
assert (cs.isInConference);
|
||||
|
||||
const {conf_hold_status, wait_hook} = opts;
|
||||
let hookOnly = true;
|
||||
|
||||
if (this.conf_hold_status !== conf_hold_status) {
|
||||
hookOnly = false;
|
||||
this.conf_hold_status = conf_hold_status;
|
||||
const hold = conf_hold_status === 'hold';
|
||||
|
||||
this.ep.api(`conference ${this.confName} ${hold ? 'mute' : 'unmute'} ${this.memberId}`)
|
||||
.catch((err) => this.logger.info({err}, 'Error muting or unmuting participant'));
|
||||
this.ep.api(`conference ${this.confName} ${hold ? 'deaf' : 'undeaf'} ${this.memberId}`)
|
||||
.catch((err) => this.logger.info({err}, 'Error deafing or undeafing participant'));
|
||||
}
|
||||
|
||||
if (wait_hook) {
|
||||
if (this.wait_hook)
|
||||
delete this.wait_hook.url;
|
||||
this.wait_hook = {url: wait_hook};
|
||||
}
|
||||
|
||||
if (hookOnly && this._playSession) {
|
||||
this._playSession.kill();
|
||||
this._playSession = null;
|
||||
}
|
||||
if (this.wait_hook?.url && this.conf_hold_status === 'hold') {
|
||||
const {dlg} = cs;
|
||||
this._doWaitHookWhileOnHold(cs, dlg, this.wait_hook);
|
||||
}
|
||||
else if (this.conf_hold_status !== 'hold' && this._playSession) {
|
||||
this._playSession.kill();
|
||||
this._playSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
async doConferenceParticipantAction(cs, opts) {
|
||||
const {action, tag, wait_hook } = opts;
|
||||
|
||||
switch (action) {
|
||||
case 'tag':
|
||||
await this.setMemberTag(tag);
|
||||
break;
|
||||
case 'untag':
|
||||
await this.clearMemberTag();
|
||||
break;
|
||||
case 'coach':
|
||||
await this.setCoachMode(tag);
|
||||
break;
|
||||
case 'uncoach':
|
||||
await this.clearCoachMode();
|
||||
break;
|
||||
case 'hold':
|
||||
this.doConferenceHold(cs, {
|
||||
conf_hold_status: 'hold',
|
||||
...(wait_hook && {wait_hook})
|
||||
});
|
||||
break;
|
||||
case 'unhold':
|
||||
this.doConferenceHold(cs, {conf_hold_status: 'unhold'});
|
||||
break;
|
||||
case 'mute':
|
||||
this.doConferenceMute(cs, {conf_mute_status: 'mute'});
|
||||
break;
|
||||
case 'unmute':
|
||||
this.doConferenceMute(cs, {conf_mute_status: 'unmute'});
|
||||
break;
|
||||
case 'kick':
|
||||
this.kickMember(cs);
|
||||
break;
|
||||
default:
|
||||
this.logger.info(`Conference:doConferenceParticipantAction - unhandled action ${action}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async _doWaitHookWhileOnHold(cs, dlg, wait_hook) {
|
||||
do {
|
||||
try {
|
||||
let tasks = [];
|
||||
if (wait_hook.url)
|
||||
tasks = await this._playHook(cs, dlg, wait_hook.url);
|
||||
if (0 === tasks.length) break;
|
||||
} catch (err) {
|
||||
if (!this.killed) {
|
||||
this.logger.info(err, `Conference:_doWait: failed retrieving wait_hook for ${this.confName}`);
|
||||
}
|
||||
this._playSession = null;
|
||||
break;
|
||||
}
|
||||
} while (!this.killed && this.conf_hold_status === 'hold');
|
||||
}
|
||||
|
||||
/**
|
||||
* mute or unmute side of the call
|
||||
*/
|
||||
mute(callSid, doMute) {
|
||||
this.doConferenceMute(this.callSession, {conf_mute_status: doMute});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add ourselves to the waitlist of sessions to be notified once
|
||||
* the conference starts
|
||||
* @param {CallSession} cs
|
||||
*/
|
||||
async _addToWaitList(cs) {
|
||||
const {addToSet} = cs.srf.locals.dbHelpers;
|
||||
const setName = getWaitListName(this.confName);
|
||||
const url = `${cs.srf.locals.serviceUrl}/v1/conference/${cs.callSid}`;
|
||||
const added = await addToSet(setName, url);
|
||||
if (added !== 1) throw new Error(`failed adding to the waitlist for conference ${this.confName}: ${added}`);
|
||||
this.logger.debug(`successfully added to the waiting list for conference ${this.confName}`);
|
||||
}
|
||||
|
||||
async _removeFromWaitList(cs) {
|
||||
const {removeFromSet} = cs.srf.locals.dbHelpers;
|
||||
const setName = getWaitListName(this.confName);
|
||||
const url = `${cs.srf.locals.serviceUrl}/v1/conference/${cs.callSid}`;
|
||||
try {
|
||||
const count = await removeFromSet(setName, url);
|
||||
this.logger.debug(`Conference:_removeFromWaitList removed ${count} from waiting list`);
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'Error removing from waiting list');
|
||||
}
|
||||
}
|
||||
|
||||
_normalizeHook(cs, hook) {
|
||||
if (typeof hook === 'object') return hook;
|
||||
const url = hook.startsWith('/') ?
|
||||
`${cs.application.requestor instanceof HttpRequestor ? cs.application.requestor.baseUrl : ''}${hook}` :
|
||||
hook;
|
||||
|
||||
return { url } ;
|
||||
}
|
||||
|
||||
/**
|
||||
* If we are the last one leaving the conference - turn out the lights.
|
||||
* Remove the conference info from the realtime database.
|
||||
* @param {*} cs
|
||||
*/
|
||||
async _doFinalMemberCheck(cs) {
|
||||
if (!this.memberId) return; // never actually joined
|
||||
|
||||
this.logger.debug(`Conference:_doFinalMemberCheck leaving ${this.confName} member count: ${this.participantCount}`);
|
||||
try {
|
||||
const response = await this.ep.api('conference', [this.confName, 'get', 'count']);
|
||||
if (response.body && confNoMatch(response.body)) this.participantCount = 0;
|
||||
else if (response.body && /^\d+$/.test(response.body)) this.participantCount = parseInt(response.body) - 1;
|
||||
this.logger.debug(`Conference:_doFinalMemberCheck conference count ${this.participantCount}`);
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Conference:_doFinalMemberCheck error retrieving count (we were probably kicked');
|
||||
}
|
||||
await this._notifyConferenceEvent(cs, 'leave');
|
||||
|
||||
/**
|
||||
* when we hang up as the last member, the current member count = 1
|
||||
* when we are kicked out of the call when the moderator leaves, the member count = 0
|
||||
*/
|
||||
if (this.participantCount === 0 || this.endConferenceOnExit) {
|
||||
const {deleteKey} = cs.srf.locals.dbHelpers;
|
||||
try {
|
||||
this._notifyConferenceEvent(cs, 'end');
|
||||
const removed = await deleteKey(this.confName);
|
||||
this.logger.info(`conf ${this.confName} deprovisioned: ${removed ? 'success' : 'failure'}`);
|
||||
}
|
||||
catch (err) {
|
||||
this.logger.error(err, `Error deprovisioning conference ${this.confName},
|
||||
might be the conference already cleaned by another moderator`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _playHook(cs, dlg, hook, allowed = [TaskName.Play, TaskName.Say, TaskName.Pause]) {
|
||||
assert(!this._playSession);
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
const json = await cs.application.requestor.request('verb:hook', hook, cs.callInfo, httpHeaders);
|
||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
|
||||
const allowedTasks = tasks.filter((t) => allowed.includes(t.name));
|
||||
if (tasks.length !== allowedTasks.length) {
|
||||
this.logger.debug({tasks, allowedTasks}, 'unsupported task');
|
||||
throw new Error(`unsupported verb in conference waitHook: only ${JSON.stringify(allowed)}`);
|
||||
}
|
||||
this.logger.debug(`Conference:_playHook: executing ${tasks.length} tasks`);
|
||||
|
||||
/* we might have been killed while off fetching waitHook */
|
||||
if (this.killed) return [];
|
||||
|
||||
if (tasks.length > 0) {
|
||||
this._playSession = new ConfirmCallSession({
|
||||
logger: this.logger,
|
||||
application: cs.application,
|
||||
dlg,
|
||||
ep: cs.ep,
|
||||
callInfo: cs.callInfo,
|
||||
accountInfo: cs.accountInfo,
|
||||
memberId: this.memberId,
|
||||
confName: this.confName,
|
||||
tasks,
|
||||
rootSpan: cs.rootSpan,
|
||||
req: cs.req
|
||||
});
|
||||
await this._playSession.exec();
|
||||
this._playSession = null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* This event triggered when we are bounced from conference when moderator leaves.
|
||||
* Get a new endpoint up and running in case the app wants to go on (e.g post-call survey)
|
||||
* @param {*} cs CallSession
|
||||
* @param {*} dlg SipDialog
|
||||
*/
|
||||
_kicked(cs, dlg) {
|
||||
this.logger.info(`Conference:kicked - I was dropped from conference ${this.confName}, task is complete`);
|
||||
if (this._playSession) {
|
||||
this._playSession.kill();
|
||||
this._playSession = null;
|
||||
}
|
||||
this.replaceEndpointAndEnd(cs);
|
||||
}
|
||||
|
||||
async replaceEndpointAndEnd(cs) {
|
||||
cs.clearConferenceDetails();
|
||||
if (this.replaced) return;
|
||||
this.replaced = true;
|
||||
try {
|
||||
this.ep = await cs.replaceEndpoint();
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'Conference:replaceEndpointAndEnd failed');
|
||||
}
|
||||
this.kill(cs);
|
||||
}
|
||||
|
||||
_notifyConferenceEvent(cs, eventName, params = {}) {
|
||||
if (this.statusEvents.includes(eventName)) {
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
params.event = eventName;
|
||||
params.duration = (Date.now() - this.conferenceStartTime.getTime()) / 1000;
|
||||
if (!params.time) params.time = (new Date()).toISOString();
|
||||
if (!params.members && typeof this.participantCount === 'number') params.members = this.participantCount;
|
||||
cs.application.requestor
|
||||
.request(
|
||||
'verb:hook',
|
||||
this.statusHook,
|
||||
Object.assign(
|
||||
params,
|
||||
Object.assign(
|
||||
{
|
||||
conferenceSid: this.confName,
|
||||
friendlyName: this.friendlyName,
|
||||
},
|
||||
cs.callInfo.toJSON()
|
||||
),
|
||||
httpHeaders
|
||||
)
|
||||
)
|
||||
.catch((err) =>
|
||||
this.logger.info(err, 'Conference:notifyConferenceEvent - error')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
__onConferenceEvent(cs, evt) {
|
||||
const eventName = evt.getHeader('Event-Subclass') ;
|
||||
if (eventName === 'conference::maintenance') {
|
||||
const action = evt.getHeader('Action') ;
|
||||
|
||||
//invoke a handler for this action, if we have defined one
|
||||
const functionName = `_on${capitalize(camelize(action))}`;
|
||||
(Conference.prototype[functionName] || unhandled).bind(this, this.logger, cs, evt)() ;
|
||||
}
|
||||
}
|
||||
|
||||
// conference event handlers
|
||||
_onAddMember(logger, cs, evt) {
|
||||
const memberId = parseInt(evt.getHeader('Member-ID')) ;
|
||||
if (this.speakOnlyTo) {
|
||||
logger.debug(`Conference:_onAddMember - member ${memberId} added to ${this.confName}, updating coaching mode`);
|
||||
this.setCoachMode(this.speakOnlyTo).catch(() => {});
|
||||
}
|
||||
else logger.debug(`Conference:_onAddMember - member ${memberId} added to conference ${this.confName}`);
|
||||
}
|
||||
_onDelMember(logger, cs, evt) {
|
||||
const memberId = parseInt(evt.getHeader('Member-ID')) ;
|
||||
this.participantCount = parseInt(evt.getHeader('Conference-Size'));
|
||||
if (memberId === this.memberId) {
|
||||
logger.info(`Conference:_onDelMember - I was dropped from conference ${this.confName}, task is complete`);
|
||||
this.replaceEndpointAndEnd(cs);
|
||||
}
|
||||
}
|
||||
|
||||
_onStartTalking(logger, cs, evt) {
|
||||
const memberId = parseInt(evt.getHeader('Member-ID')) ;
|
||||
const size = this.participantCount = parseInt(evt.getHeader('Conference-Size'));
|
||||
if (memberId === this.memberId) {
|
||||
const time = new Date(evt.getHeader('Event-Date-Timestamp') / 1000).toISOString();
|
||||
this._notifyConferenceEvent(cs, 'start-talking', {
|
||||
time,
|
||||
members: size
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_onStopTalking(logger, cs, evt) {
|
||||
const memberId = parseInt(evt.getHeader('Member-ID')) ;
|
||||
const size = this.participantCount = parseInt(evt.getHeader('Conference-Size'));
|
||||
if (memberId === this.memberId) {
|
||||
const time = new Date(evt.getHeader('Event-Date-Timestamp') / 1000).toISOString();
|
||||
this._notifyConferenceEvent(cs, 'stop-talking', {
|
||||
time,
|
||||
members: size
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_onTag(logger, cs, evt) {
|
||||
const memberId = parseInt(evt.getHeader('Member-ID')) ;
|
||||
const tag = evt.getHeader('Tag') || '';
|
||||
if (memberId !== this.memberId && this.speakOnlyTo) {
|
||||
logger.info(`Conference:_onTag - member ${memberId} set tag to '${tag }'; updating coach mode accordingly`);
|
||||
this.setCoachMode(this.speakOnlyTo).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the conference to "coaching" mode, where the audio of the participant is only heard
|
||||
* by a subset of the participants in the conference.
|
||||
* We do this by first getting all of the members who do *not* have this tag, and then
|
||||
* we configure this members audio to not be sent to them.
|
||||
* @param {string} speakOnlyTo - tag of the members who should receive our audio
|
||||
*
|
||||
* N.B.: this feature requires jambonz patches to freeswitch mod_conference
|
||||
*/
|
||||
async setCoachMode(speakOnlyTo) {
|
||||
this.speakOnlyTo = speakOnlyTo;
|
||||
if (!this.memberId) {
|
||||
this.logger.info('Conference:_setCoachMode: no member id yet');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const members = (await this.ep.getNonMatchingConfParticipants(this.confName, speakOnlyTo))
|
||||
.filter((m) => m !== this.memberId);
|
||||
if (members.length === 0) {
|
||||
this.logger.info({members}, 'Conference:_setCoachMode: all participants have the tag, so all will hear me');
|
||||
if (this.coaching.length) {
|
||||
await this.ep.api('conference', [this.confName, 'relate', this.memberId, this.coaching.join(','), 'clear']);
|
||||
this.coaching = [];
|
||||
}
|
||||
}
|
||||
else {
|
||||
const memberList = members.join(',');
|
||||
this.logger.info(`Conference:_setCoachMode: my audio will NOT be sent to ${memberList}`);
|
||||
await this.ep.api('conference', [this.confName, 'relate', this.memberId, memberList, 'nospeak']);
|
||||
this.coaching = members;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error({err, speakOnlyTo}, '_setCoachMode: Error');
|
||||
}
|
||||
}
|
||||
|
||||
async clearCoachMode() {
|
||||
if (!this.memberId) return;
|
||||
try {
|
||||
if (this.coaching.length === 0) {
|
||||
this.logger.info('Conference:_clearCoachMode: no coaching mode to clear');
|
||||
}
|
||||
else {
|
||||
const memberList = this.coaching.join(',');
|
||||
this.logger.info(`Conference:_clearCoachMode: now sending my audio to all, including ${memberList}`);
|
||||
await this.ep.api('conference', [this.confName, 'relate', this.memberId, memberList, 'clear']);
|
||||
}
|
||||
this.speakOnlyTo = null;
|
||||
this.coaching = [];
|
||||
} catch (err) {
|
||||
this.logger.error({err}, '_clearCoachMode: Error');
|
||||
}
|
||||
}
|
||||
|
||||
async setMemberTag(tag) {
|
||||
try {
|
||||
await this.ep.api('conference', [this.confName, 'tag', this.memberId, tag]);
|
||||
this.logger.info(`Conference:setMemberTag: set tag for ${this.memberId} to ${tag}`);
|
||||
this.memberTag = tag;
|
||||
} catch (err) {
|
||||
this.logger.error({err}, `Error setting tag for ${this.memberId} to ${tag}`);
|
||||
}
|
||||
}
|
||||
|
||||
async clearMemberTag() {
|
||||
try {
|
||||
await this.ep.api('conference', [this.confName, 'tag', this.memberId]);
|
||||
this.logger.info(`Conference:setMemberTag: clearing tag for ${this.memberId}`);
|
||||
this.memberTag = null;
|
||||
} catch (err) {
|
||||
this.logger.error({err}, `Error clearing tag for ${this.memberId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async kickMember(cs) {
|
||||
assert(cs.isInConference);
|
||||
try {
|
||||
await this.ep.api('conference', [this.confName, 'kick', this.memberId]);
|
||||
this.logger.info(`Conference:kickMember: kick ${this.memberId} out of conference ${this.confName}`);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, `Error kicking member out of conference for ${this.memberId}`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Conference;
|
||||
364
lib/tasks/config.js
Normal file
364
lib/tasks/config.js
Normal file
@@ -0,0 +1,364 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
const parseDecibels = require('../utils/parse-decibels');
|
||||
|
||||
class TaskConfig extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
|
||||
[
|
||||
'synthesizer',
|
||||
'recognizer',
|
||||
'bargeIn',
|
||||
'record',
|
||||
'listen',
|
||||
'transcribe',
|
||||
'fillerNoise',
|
||||
'actionHookDelayAction',
|
||||
'boostAudioSignal',
|
||||
'vad',
|
||||
'ttsStream',
|
||||
'autoStreamTts'
|
||||
].forEach((k) => this[k] = this.data[k] || {});
|
||||
|
||||
if ('notifyEvents' in this.data) {
|
||||
this.notifyEvents = !!this.data.notifyEvents;
|
||||
}
|
||||
|
||||
if (this.bargeIn.enable) {
|
||||
this.gatherOpts = {
|
||||
verb: 'gather',
|
||||
timeout: 0,
|
||||
bargein: true,
|
||||
input: ['speech']
|
||||
};
|
||||
[
|
||||
'finishOnKey', 'input', 'numDigits', 'minDigits', 'maxDigits',
|
||||
'interDigitTimeout', 'bargein', 'dtmfBargein', 'minBargeinWordCount', 'actionHook'
|
||||
].forEach((k) => {
|
||||
const val = this.bargeIn[k];
|
||||
if (val !== undefined && val !== null) this.gatherOpts[k] = val;
|
||||
});
|
||||
}
|
||||
if (this.transcribe?.enable) {
|
||||
this.transcribeOpts = {
|
||||
verb: 'transcribe',
|
||||
...this.transcribe
|
||||
};
|
||||
delete this.transcribeOpts.enable;
|
||||
}
|
||||
if (this.ttsStream.enable) {
|
||||
this.sayOpts = {
|
||||
verb: 'say',
|
||||
stream: true
|
||||
};
|
||||
}
|
||||
|
||||
if (this.data.reset) {
|
||||
if (typeof this.data.reset === 'string') this.data.reset = [this.data.reset];
|
||||
}
|
||||
else this.data.reset = [];
|
||||
|
||||
if (this.bargeIn.sticky) this.autoEnable = true;
|
||||
this.preconditions = (this.bargeIn.enable ||
|
||||
this.record?.action ||
|
||||
this.listen?.url ||
|
||||
this.data.amd ||
|
||||
'boostAudioSignal' in this.data ||
|
||||
this.transcribe?.enable) ?
|
||||
TaskPreconditions.Endpoint :
|
||||
TaskPreconditions.None;
|
||||
|
||||
this.onHoldMusic = this.data.onHoldMusic;
|
||||
}
|
||||
|
||||
get name() { return TaskName.Config; }
|
||||
|
||||
get hasSynthesizer() { return Object.keys(this.synthesizer).length; }
|
||||
get hasRecognizer() { return Object.keys(this.recognizer).length; }
|
||||
get hasRecording() { return Object.keys(this.record).length; }
|
||||
get hasListen() { return Object.keys(this.listen).length; }
|
||||
get hasTranscribe() { return Object.keys(this.transcribe).length; }
|
||||
get hasDub() { return Object.keys(this.dub).length; }
|
||||
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 hasTtsStream() { return Object.keys(this.ttsStream).length; }
|
||||
|
||||
get summary() {
|
||||
const phrase = [];
|
||||
|
||||
/* reset recognizer and/or synthesizer to default values? */
|
||||
if (this.data.reset.length) phrase.push(`reset ${this.data.reset.join(',')}`);
|
||||
|
||||
if (this.bargeIn.enable) phrase.push('enable barge-in');
|
||||
if (this.hasSynthesizer) {
|
||||
const {vendor:v, language:l, voice, label} = this.synthesizer;
|
||||
const s = `{${v},${l},${voice},${label || 'None'}}`;
|
||||
phrase.push(`set synthesizer${s}`);
|
||||
}
|
||||
if (this.hasRecognizer) {
|
||||
const {vendor:v, language:l, label} = this.recognizer;
|
||||
const s = `{${v},${l},${label || 'None'}}`;
|
||||
phrase.push(`set recognizer${s}`);
|
||||
}
|
||||
if (this.hasRecording) phrase.push(this.record.action);
|
||||
if (this.hasListen) {
|
||||
phrase.push(this.listen.enable ? `listen ${this.listen.url}` : 'stop listen');
|
||||
}
|
||||
if (this.hasTranscribe) {
|
||||
phrase.push(this.transcribe.enable ? `transcribe ${this.transcribe.transcriptionHook}` : 'stop transcribe');
|
||||
}
|
||||
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.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'}`);
|
||||
return `${this.name}{${phrase.join(',')}}`;
|
||||
}
|
||||
|
||||
async exec(cs, {ep} = {}) {
|
||||
await super.exec(cs);
|
||||
|
||||
if (this.notifyEvents) {
|
||||
this.logger.debug(`turning event notification ${this.notifyEvents ? 'on' : 'off'}`);
|
||||
cs.notifyEvents = !!this.data.notifyEvents;
|
||||
}
|
||||
|
||||
if (this.onHoldMusic) {
|
||||
cs.onHoldMusic = this.onHoldMusic;
|
||||
}
|
||||
|
||||
if (this.data.amd) {
|
||||
this.startAmd = cs.startAmd;
|
||||
this.stopAmd = cs.stopAmd;
|
||||
this.on('amd', this._onAmdEvent.bind(this, cs));
|
||||
|
||||
try {
|
||||
this.ep = ep;
|
||||
await this.startAmd(cs, ep, this, this.data.amd);
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Config:exec - Error calling startAmd');
|
||||
}
|
||||
}
|
||||
|
||||
this.data.reset.forEach((k) => {
|
||||
if (k === 'synthesizer') cs.resetSynthesizer();
|
||||
else if (k === 'recognizer') cs.resetRecognizer();
|
||||
});
|
||||
|
||||
if (this.hasSynthesizer) {
|
||||
cs.synthesizer = this.synthesizer;
|
||||
cs.speechSynthesisVendor = this.synthesizer.vendor !== 'default'
|
||||
? this.synthesizer.vendor
|
||||
: cs.speechSynthesisVendor;
|
||||
cs.speechSynthesisLabel = this.synthesizer.label === 'default'
|
||||
? cs.speechSynthesisLabel : this.synthesizer.label;
|
||||
cs.speechSynthesisLanguage = this.synthesizer.language !== 'default'
|
||||
? this.synthesizer.language
|
||||
: cs.speechSynthesisLanguage;
|
||||
cs.speechSynthesisVoice = this.synthesizer.voice !== 'default'
|
||||
? this.synthesizer.voice
|
||||
: cs.speechSynthesisVoice;
|
||||
|
||||
// fallback vendor
|
||||
cs.fallbackSpeechSynthesisVendor = this.synthesizer.fallbackVendor !== 'default'
|
||||
? this.synthesizer.fallbackVendor
|
||||
: cs.fallbackSpeechSynthesisVendor;
|
||||
cs.fallbackSpeechSynthesisLabel = this.synthesizer.fallbackLabel === 'default'
|
||||
? cs.fallbackSpeechSynthesisLabel : this.synthesizer.fallbackLabel;
|
||||
cs.fallbackSpeechSynthesisLanguage = this.synthesizer.fallbackLanguage !== 'default'
|
||||
? this.synthesizer.fallbackLanguage
|
||||
: cs.fallbackSpeechSynthesisLanguage;
|
||||
cs.fallbackSpeechSynthesisVoice = this.synthesizer.fallbackVoice !== 'default'
|
||||
? this.synthesizer.fallbackVoice
|
||||
: cs.fallbackSpeechSynthesisVoice;
|
||||
// new vendor is set, reset fallback vendor
|
||||
cs.hasFallbackTts = false;
|
||||
this.logger.info({synthesizer: this.synthesizer}, 'Config: updated synthesizer');
|
||||
}
|
||||
if (this.hasRecognizer) {
|
||||
cs.recognizer = this.recognizer;
|
||||
cs.speechRecognizerVendor = this.recognizer.vendor !== 'default'
|
||||
? this.recognizer.vendor
|
||||
: cs.speechRecognizerVendor;
|
||||
cs.speechRecognizerLabel = this.recognizer.label === 'default'
|
||||
? cs.speechRecognizerLabel : this.recognizer.label;
|
||||
cs.speechRecognizerLanguage = this.recognizer.language !== undefined && this.recognizer.language !== 'default'
|
||||
? this.recognizer.language
|
||||
: cs.speechRecognizerLanguage;
|
||||
|
||||
//fallback
|
||||
cs.fallbackSpeechRecognizerVendor = this.recognizer.fallbackVendor !== undefined &&
|
||||
this.recognizer.fallbackVendor !== 'default'
|
||||
? this.recognizer.fallbackVendor
|
||||
: cs.fallbackSpeechRecognizerVendor;
|
||||
cs.fallbackSpeechRecognizerLabel = this.recognizer.fallbackLabel === 'default' ?
|
||||
cs.fallbackSpeechRecognizerLabel :
|
||||
this.recognizer.fallbackLabel;
|
||||
cs.fallbackSpeechRecognizerLanguage = this.recognizer.fallbackLanguage !== undefined &&
|
||||
this.recognizer.fallbackLanguage !== 'default'
|
||||
? this.recognizer.fallbackLanguage
|
||||
: cs.fallbackSpeechRecognizerLanguage;
|
||||
|
||||
cs.isContinuousAsr = typeof this.recognizer.asrTimeout === 'number' ? true : false;
|
||||
if (cs.isContinuousAsr) {
|
||||
cs.asrTimeout = this.recognizer.asrTimeout;
|
||||
cs.asrDtmfTerminationDigit = this.recognizer.asrDtmfTerminationDigit;
|
||||
}
|
||||
if (Array.isArray(this.recognizer.hints)) {
|
||||
const obj = {hints: this.recognizer.hints};
|
||||
if (typeof this.recognizer.hintsBoost === 'number') {
|
||||
obj.hintsBoost = this.recognizer.hintsBoost;
|
||||
}
|
||||
cs.globalSttHints = obj;
|
||||
}
|
||||
if (Array.isArray(this.recognizer.altLanguages)) {
|
||||
this.logger.info({altLanguages: this.recognizer.altLanguages}, 'Config: updated altLanguages');
|
||||
cs.altLanguages = this.recognizer.altLanguages;
|
||||
}
|
||||
if ('punctuation' in this.recognizer) {
|
||||
cs.globalSttPunctuation = this.recognizer.punctuation;
|
||||
}
|
||||
// new vendor is set, reset fallback vendor
|
||||
cs.hasFallbackAsr = false;
|
||||
this.logger.info({
|
||||
recognizer: this.recognizer,
|
||||
isContinuousAsr: cs.isContinuousAsr
|
||||
}, 'Config: updated recognizer');
|
||||
}
|
||||
if ('enable' in this.bargeIn) {
|
||||
if (this.bargeIn.enable === true && this.gatherOpts) {
|
||||
this.gatherOpts.recognizer = this.hasRecognizer ?
|
||||
this.recognizer :
|
||||
{
|
||||
vendor: cs.speechRecognizerVendor,
|
||||
language: cs.speechRecognizerLanguage
|
||||
};
|
||||
this.logger.info({opts: this.gatherOpts}, 'Config: enabling bargeIn');
|
||||
cs.enableBotMode(this.gatherOpts, this.autoEnable);
|
||||
}
|
||||
else if (this.bargeIn.enable === false) {
|
||||
this.logger.info('Config: disabling bargeIn');
|
||||
cs.disableBotMode();
|
||||
}
|
||||
}
|
||||
if (this.record.action) {
|
||||
try {
|
||||
await cs.notifyRecordOptions(this.record);
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Config: error starting recording');
|
||||
}
|
||||
}
|
||||
if (this.hasListen) {
|
||||
const {enable, ...opts} = this.listen;
|
||||
if (enable) {
|
||||
this.logger.debug({opts}, 'Config: enabling listen');
|
||||
cs.startBackgroundTask('listen', {verb: 'listen', ...opts});
|
||||
} else {
|
||||
this.logger.info('Config: disabling listen');
|
||||
cs.stopBackgroundTask('listen');
|
||||
}
|
||||
}
|
||||
if (this.hasTranscribe) {
|
||||
if (this.transcribe.enable) {
|
||||
if (!this.transcribeOpts.recognizer) {
|
||||
this.transcribeOpts.recognizer = this.hasRecognizer ?
|
||||
this.recognizer :
|
||||
{
|
||||
vendor: cs.speechRecognizerVendor,
|
||||
language: cs.speechRecognizerLanguage
|
||||
};
|
||||
}
|
||||
this.logger.debug(this.transcribeOpts, 'Config: enabling transcribe');
|
||||
cs.startBackgroundTask('transcribe', this.transcribeOpts);
|
||||
} else {
|
||||
this.logger.info('Config: disabling transcribe');
|
||||
cs.stopBackgroundTask('transcribe');
|
||||
}
|
||||
}
|
||||
if (Object.keys(this.actionHookDelayAction).length !== 0) {
|
||||
cs.actionHookDelayProperties = this.actionHookDelayAction;
|
||||
}
|
||||
if (this.data.sipRequestWithinDialogHook) {
|
||||
cs.sipRequestWithinDialogHook = this.data.sipRequestWithinDialogHook;
|
||||
}
|
||||
|
||||
if ('boostAudioSignal' in this.data) {
|
||||
const db = parseDecibels(this.data.boostAudioSignal);
|
||||
this.logger.info(`Config: boosting audio signal by ${db} dB`);
|
||||
const args = [ep.uuid, 'setGain', db];
|
||||
ep.api('uuid_dub', args).catch((err) => {
|
||||
this.logger.error(err, 'Error boosting audio signal');
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
if (!enable) cs.disableFillerNoise();
|
||||
else {
|
||||
cs.enableFillerNoise(opts);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.hasVad) {
|
||||
cs.vad = {
|
||||
enable: this.vad.enable || false,
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
if (this.hasReferHook) {
|
||||
cs.referHook = this.data.referHook;
|
||||
}
|
||||
|
||||
if (this.ttsStream.enable && this.sayOpts) {
|
||||
this.sayOpts.synthesizer = this.hasSynthesizer ? this.synthesizer : {
|
||||
vendor: cs.speechSynthesisVendor,
|
||||
language: cs.speechSynthesisLanguage,
|
||||
voice: cs.speechSynthesisVoice,
|
||||
...(cs.speechSynthesisLabel && {
|
||||
label: cs.speechSynthesisLabel
|
||||
})
|
||||
};
|
||||
this.logger.info({opts: this.gatherOpts}, 'Config: enabling ttsStream');
|
||||
cs.enableBackgroundTtsStream(this.sayOpts);
|
||||
}
|
||||
// only disable ttsStream if it specifically set to false
|
||||
else if (this.ttsStream.enable === false) {
|
||||
this.logger.info('Config: disabling ttsStream');
|
||||
cs.disableTtsStream();
|
||||
}
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
//if (this.ep && this.stopAmd) this.stopAmd(this.ep, this);
|
||||
}
|
||||
|
||||
_onAmdEvent(cs, evt) {
|
||||
this.logger.info({evt}, 'Config:_onAmdEvent');
|
||||
const {actionHook} = this.data.amd;
|
||||
this.performHook(cs, actionHook, evt)
|
||||
.catch((err) => {
|
||||
this.logger.error({err}, 'Config:_onAmdEvent - error calling actionHook');
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskConfig;
|
||||
157
lib/tasks/dequeue.js
Normal file
157
lib/tasks/dequeue.js
Normal file
@@ -0,0 +1,157 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions, DequeueResults, BONG_TONE} = require('../utils/constants');
|
||||
const Emitter = require('events');
|
||||
const bent = require('bent');
|
||||
const assert = require('assert');
|
||||
const { sleepFor } = require('../utils/helpers');
|
||||
|
||||
const getUrl = (cs) => `${cs.srf.locals.serviceUrl}/v1/dequeue/${cs.callSid}`;
|
||||
|
||||
class TaskDequeue extends Task {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
this.queueName = this.data.name;
|
||||
this.timeout = this.data.timeout || 0;
|
||||
this.beep = this.data.beep === true;
|
||||
this.callSid = this.data.callSid;
|
||||
|
||||
this.emitter = new Emitter();
|
||||
this.state = DequeueResults.Timeout;
|
||||
}
|
||||
|
||||
get name() { return TaskName.Dequeue; }
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
this.ep = ep;
|
||||
this.queueName = `queue:${cs.accountSid}:${this.queueName}`;
|
||||
|
||||
const url = await this._getMemberFromQueue(cs);
|
||||
if (!url) this.performAction({dequeueResult: 'timeout'}).catch((err) => {});
|
||||
else {
|
||||
try {
|
||||
await this._dequeueUrl(cs, ep, url);
|
||||
this.performAction({dequeueResult: 'complete'}).catch((err) => {});
|
||||
} catch (err) {
|
||||
this.emitter.removeAllListeners();
|
||||
this.performAction({dequeueResult: 'hangup'}).catch((err) => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
if (this.state === DequeueResults.Bridged) {
|
||||
this.logger.info(`TaskDequeue:kill - notifying partner we are going away ${this.partnerUrl}`);
|
||||
bent('POST', 202)(this.partnerUrl, {event: 'hangup'}).catch((err) => {
|
||||
this.logger.info(err, 'TaskDequeue:kill error notifying partner of hangup');
|
||||
});
|
||||
}
|
||||
this.emitter.emit('kill');
|
||||
}
|
||||
|
||||
_getMemberFromQueue(cs) {
|
||||
const {retrieveFromSortedSet, retrieveByPatternSortedSet} = cs.srf.locals.dbHelpers;
|
||||
|
||||
return new Promise(async(resolve) => {
|
||||
let timer;
|
||||
let timedout = false, found = false;
|
||||
if (this.timeout > 0) {
|
||||
timer = setTimeout(() => {
|
||||
this.logger.info(`TaskDequeue:_getMemberFromQueue timed out after ${this.timeout}s`);
|
||||
timedout = true;
|
||||
resolve();
|
||||
}, this.timeout * 1000);
|
||||
}
|
||||
|
||||
await sleepFor(1000); // to avoid clipping if we dial and immediately connect
|
||||
|
||||
do {
|
||||
try {
|
||||
let url;
|
||||
if (this.callSid) {
|
||||
const r = await retrieveByPatternSortedSet(this.queueName, `*${this.callSid}`);
|
||||
url = r[0];
|
||||
} else {
|
||||
url = await retrieveFromSortedSet(this.queueName);
|
||||
}
|
||||
if (url) {
|
||||
found = true;
|
||||
clearTimeout(timer);
|
||||
this.logger.info(`TaskDequeue:_getMemberFromQueue popped ${url} from queue ${this.queueName}`);
|
||||
resolve(url);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.debug({err}, 'TaskDequeue:_getMemberFromQueue error Sorted Set');
|
||||
}
|
||||
await sleepFor(5000);
|
||||
} while (!this.killed && !timedout && !found);
|
||||
});
|
||||
}
|
||||
|
||||
_dequeueUrl(cs, ep, url) {
|
||||
this.partnerUrl = url;
|
||||
|
||||
return new Promise(async(resolve, reject) => {
|
||||
let bridgeTimer;
|
||||
this.emitter
|
||||
.on('bridged', () => {
|
||||
clearTimeout(bridgeTimer);
|
||||
this.state = DequeueResults.Bridged;
|
||||
})
|
||||
.on('hangup', () => {
|
||||
this.logger.info('TaskDequeue:_dequeueUrl hangup from partner');
|
||||
resolve();
|
||||
})
|
||||
.on('kill', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
// now notify partner to bridge to me
|
||||
try {
|
||||
// TODO: if we have a confirmHook, retrieve the app and pass it on
|
||||
await bent('POST', 202)(url, {
|
||||
event: 'dequeue',
|
||||
dequeueSipAddress: cs.srf.locals.localSipAddress,
|
||||
epUuid: ep.uuid,
|
||||
notifyUrl: getUrl(cs),
|
||||
dequeuer: cs.callInfo.toJSON()
|
||||
});
|
||||
this.logger.info(`TaskDequeue:_dequeueUrl successfully sent POST to ${url}`);
|
||||
bridgeTimer = setTimeout(() => reject(new Error('bridge timeout')), 20000);
|
||||
} catch (err) {
|
||||
this.logger.info({err, url}, `TaskDequeue:_dequeueUrl error dequeueing from ${this.queueName}, try again`);
|
||||
reject(new Error('bridge failure'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async notifyQueueEvent(cs, opts) {
|
||||
if (opts.event === 'ready') {
|
||||
assert(opts.notifyUrl && opts.epUuid);
|
||||
this.partnerUrl = opts.notifyUrl;
|
||||
this.logger.info({opts}, `TaskDequeue:notifyDequeueEvent: about to bridge member from ${this.queueName}`);
|
||||
|
||||
if (this.beep) {
|
||||
this.logger.debug({opts}, `TaskDequeue:notifyDequeueEvent: playing beep tone ${this.queueName}`);
|
||||
await this.ep.play(BONG_TONE).catch((err) => {
|
||||
this.logger.error(err, 'TaskDequeue:notifyDequeueEvent error playing beep');
|
||||
});
|
||||
}
|
||||
await this.ep.bridge(opts.epUuid);
|
||||
this.emitter.emit('bridged');
|
||||
this.logger.info({opts}, `TaskDequeue:notifyDequeueEvent: successfully bridged member from ${this.queueName}`);
|
||||
}
|
||||
else if (opts.event === 'hangup') {
|
||||
this.emitter.emit('hangup');
|
||||
}
|
||||
else {
|
||||
this.logger.error({opts}, 'TaskDequeue:notifyDequeueEvent - unsupported event/payload');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = TaskDequeue;
|
||||
File diff suppressed because it is too large
Load Diff
70
lib/tasks/dialogflow/digit-buffer.js
Normal file
70
lib/tasks/dialogflow/digit-buffer.js
Normal file
@@ -0,0 +1,70 @@
|
||||
const Emitter = require('events');
|
||||
|
||||
/**
|
||||
* A dtmf collector
|
||||
* @class
|
||||
*/
|
||||
class DigitBuffer extends Emitter {
|
||||
/**
|
||||
* Creates a DigitBuffer
|
||||
* @param {*} logger - a pino logger
|
||||
* @param {*} opts - dtmf collection instructions
|
||||
*/
|
||||
constructor(logger, opts) {
|
||||
super();
|
||||
this.logger = logger;
|
||||
this.minDigits = opts.min || 1;
|
||||
this.maxDigits = opts.max || 99;
|
||||
this.termDigit = opts.term;
|
||||
this.interdigitTimeout = opts.idt || 8000;
|
||||
this.template = opts.template;
|
||||
this.buffer = '';
|
||||
this.logger.debug(`digitbuffer min: ${this.minDigits} max: ${this.maxDigits} term digit: ${this.termDigit}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* process a received dtmf digit
|
||||
* @param {String} a single digit entered by the caller
|
||||
*/
|
||||
process(digit) {
|
||||
this.logger.debug(`digitbuffer process: ${digit}`);
|
||||
if (digit === this.termDigit) return this._fulfill();
|
||||
this.buffer += digit;
|
||||
if (this.buffer.length === this.maxDigits) return this._fulfill();
|
||||
if (this.buffer.length >= this.minDigits) this._startInterDigitTimer();
|
||||
this.logger.debug(`digitbuffer buffer: ${this.buffer}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* clear the digit buffer
|
||||
*/
|
||||
flush() {
|
||||
if (this.idtimer) clearTimeout(this.idtimer);
|
||||
this.buffer = '';
|
||||
}
|
||||
|
||||
_fulfill() {
|
||||
this.logger.debug(`digit buffer fulfilled with ${this.buffer}`);
|
||||
if (this.template && this.template.includes('${digits}')) {
|
||||
const text = this.template.replace('${digits}', this.buffer);
|
||||
this.logger.info(`reporting dtmf as ${text}`);
|
||||
this.emit('fulfilled', text);
|
||||
}
|
||||
else {
|
||||
this.emit('fulfilled', this.buffer);
|
||||
}
|
||||
this.flush();
|
||||
}
|
||||
|
||||
_startInterDigitTimer() {
|
||||
if (this.idtimer) clearTimeout(this.idtimer);
|
||||
this.idtimer = setTimeout(this._onInterDigitTimeout.bind(this), this.interdigitTimeout);
|
||||
}
|
||||
|
||||
_onInterDigitTimeout() {
|
||||
this.logger.debug('digit buffer timeout');
|
||||
this._fulfill();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DigitBuffer;
|
||||
528
lib/tasks/dialogflow/index.js
Normal file
528
lib/tasks/dialogflow/index.js
Normal file
@@ -0,0 +1,528 @@
|
||||
const Task = require('../task');
|
||||
const {TaskName, TaskPreconditions} = require('../../utils/constants');
|
||||
const Intent = require('./intent');
|
||||
const DigitBuffer = require('./digit-buffer');
|
||||
const Transcription = require('./transcription');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
|
||||
class Dialogflow extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
this.credentials = this.data.credentials;
|
||||
|
||||
/* set project id with environment and region (optionally) */
|
||||
if (this.data.environment && this.data.region) {
|
||||
this.project = `${this.data.project}:${this.data.environment}:${this.data.region}`;
|
||||
}
|
||||
else if (this.data.environment) {
|
||||
this.project = `${this.data.project}:${this.data.environment}`;
|
||||
}
|
||||
else if (this.data.region) {
|
||||
this.project = `${this.data.project}::${this.data.region}`;
|
||||
}
|
||||
else {
|
||||
this.project = this.data.project;
|
||||
}
|
||||
|
||||
this.lang = this.data.lang || 'en-US';
|
||||
this.welcomeEvent = this.data.welcomeEvent || '';
|
||||
if (this.welcomeEvent.length && this.data.welcomeEventParams && typeof this.data.welcomeEventParams === 'object') {
|
||||
this.welcomeEventParams = this.data.welcomeEventParams;
|
||||
}
|
||||
if (this.data.noInputTimeout) this.noInputTimeout = this.data.noInputTimeout * 1000;
|
||||
else this.noInputTimeout = 20000;
|
||||
this.noInputEvent = this.data.noInputEvent || 'actions_intent_NO_INPUT';
|
||||
this.passDtmfAsInputText = this.passDtmfAsInputText === true;
|
||||
if (this.data.eventHook) this.eventHook = this.data.eventHook;
|
||||
if (this.eventHook && Array.isArray(this.data.events)) {
|
||||
this.events = this.data.events;
|
||||
}
|
||||
else if (this.eventHook) {
|
||||
// send all events by default - except interim transcripts
|
||||
this.events = [
|
||||
'intent',
|
||||
'transcription',
|
||||
'dtmf',
|
||||
'start-play',
|
||||
'stop-play',
|
||||
'no-input'
|
||||
];
|
||||
}
|
||||
else {
|
||||
this.events = [];
|
||||
}
|
||||
if (this.data.actionHook) this.actionHook = this.data.actionHook;
|
||||
if (this.data.thinkingMusic) this.thinkingMusic = this.data.thinkingMusic;
|
||||
if (this.data.tts) {
|
||||
this.vendor = this.data.tts.vendor || 'default';
|
||||
this.language = this.data.tts.language || 'default';
|
||||
this.voice = this.data.tts.voice || 'default';
|
||||
this.speechSynthesisLabel = this.data.tts.label;
|
||||
|
||||
// fallback tts
|
||||
this.fallbackVendor = this.data.tts.fallbackVendor || 'default';
|
||||
this.fallbackLanguage = this.data.tts.fallbackLanguage || 'default';
|
||||
this.fallbackVoice = this.data.tts.fallbackLanguage || 'default';
|
||||
this.fallbackLabel = this.data.tts.fallbackLabel;
|
||||
}
|
||||
this.bargein = this.data.bargein;
|
||||
}
|
||||
|
||||
get name() { return TaskName.Dialogflow; }
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
|
||||
try {
|
||||
await this.init(cs, ep);
|
||||
|
||||
this.logger.debug(`starting dialogflow bot ${this.project}`);
|
||||
|
||||
// kick it off
|
||||
const baseArgs = `${this.ep.uuid} ${this.project} ${this.lang} ${this.welcomeEvent}`;
|
||||
if (this.welcomeEventParams) {
|
||||
this.ep.api('dialogflow_start', `${baseArgs} '${JSON.stringify(this.welcomeEventParams)}'`);
|
||||
}
|
||||
else if (this.welcomeEvent.length) {
|
||||
this.ep.api('dialogflow_start', baseArgs);
|
||||
}
|
||||
else {
|
||||
this.ep.api('dialogflow_start', `${this.ep.uuid} ${this.project} ${this.lang}`);
|
||||
}
|
||||
this.logger.debug(`started dialogflow bot ${this.project}`);
|
||||
|
||||
await this.awaitTaskDone();
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'Dialogflow:exec error');
|
||||
}
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
if (this.ep.connected) {
|
||||
this.logger.debug('TaskDialogFlow:kill');
|
||||
this.ep.removeCustomEventListener('dialogflow::intent');
|
||||
this.ep.removeCustomEventListener('dialogflow::transcription');
|
||||
this.ep.removeCustomEventListener('dialogflow::audio_provided');
|
||||
this.ep.removeCustomEventListener('dialogflow::end_of_utterance');
|
||||
this.ep.removeCustomEventListener('dialogflow::error');
|
||||
|
||||
this._clearNoinputTimer();
|
||||
|
||||
if (!this.reportedFinalAction) this.performAction({dialogflowResult: 'caller hungup'})
|
||||
.catch((err) => this.logger.error({err}, 'dialogflow - error w/ action webook'));
|
||||
|
||||
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
}
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
async init(cs, ep) {
|
||||
this.ep = ep;
|
||||
try {
|
||||
if (this.vendor === 'default') {
|
||||
this.vendor = cs.speechSynthesisVendor;
|
||||
this.language = cs.speechSynthesisLanguage;
|
||||
this.voice = cs.speechSynthesisVoice;
|
||||
this.speechSynthesisLabel = cs.speechSynthesisLabel;
|
||||
}
|
||||
if (this.fallbackVendor === 'default') {
|
||||
this.fallbackVendor = cs.fallbackSpeechSynthesisVendor;
|
||||
this.fallbackLanguage = cs.fallbackSpeechSynthesisLanguage;
|
||||
this.fallbackVoice = cs.fallbackSpeechSynthesisVoice;
|
||||
this.fallbackLabel = cs.fallbackSpeechSynthesisLabel;
|
||||
}
|
||||
this.ttsCredentials = cs.getSpeechCredentials(this.vendor, 'tts', this.speechSynthesisLabel);
|
||||
|
||||
this.ep.addCustomEventListener('dialogflow::intent', this._onIntent.bind(this, ep, cs));
|
||||
this.ep.addCustomEventListener('dialogflow::transcription', this._onTranscription.bind(this, ep, cs));
|
||||
this.ep.addCustomEventListener('dialogflow::audio_provided', this._onAudioProvided.bind(this, ep, cs));
|
||||
this.ep.addCustomEventListener('dialogflow::end_of_utterance', this._onEndOfUtterance.bind(this, ep, cs));
|
||||
this.ep.addCustomEventListener('dialogflow::error', this._onError.bind(this, ep, cs));
|
||||
|
||||
const obj = typeof this.credentials === 'string' ? JSON.parse(this.credentials) : this.credentials;
|
||||
const creds = JSON.stringify(obj);
|
||||
await this.ep.set('GOOGLE_APPLICATION_CREDENTIALS', creds);
|
||||
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'Error setting credentials');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An intent has been returned. Since we are using SINGLE_UTTERANCE on the dialogflow side,
|
||||
* we may get an empty intent, signified by the lack of a 'response_id' attribute.
|
||||
* In such a case, we just start another StreamingIntentDetectionRequest.
|
||||
* @param {*} ep - media server endpoint
|
||||
* @param {*} evt - event data
|
||||
*/
|
||||
async _onIntent(ep, cs, evt) {
|
||||
const intent = new Intent(this.logger, evt);
|
||||
|
||||
if (intent.isEmpty) {
|
||||
/**
|
||||
* An empty intent is returned in 3 conditions:
|
||||
* 1. Our no-input timer fired
|
||||
* 2. We collected dtmf that needs to be fed to dialogflow
|
||||
* 3. A normal dialogflow timeout
|
||||
*/
|
||||
if (this.noinput && this.greetingPlayed) {
|
||||
this.logger.info('no input timer fired, reprompting..');
|
||||
this.noinput = false;
|
||||
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang} ${this.noInputEvent}`);
|
||||
}
|
||||
else if (this.dtmfEntry && this.greetingPlayed) {
|
||||
this.logger.info('dtmf detected, reprompting..');
|
||||
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang} none \'${this.dtmfEntry}\'`);
|
||||
this.dtmfEntry = null;
|
||||
}
|
||||
else if (this.greetingPlayed) {
|
||||
this.logger.info('starting another intent');
|
||||
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang}`);
|
||||
}
|
||||
else {
|
||||
this.logger.info('got empty intent');
|
||||
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.events.includes('intent')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'intent', data: evt});
|
||||
}
|
||||
|
||||
// clear the no-input timer and the digit buffer
|
||||
this._clearNoinputTimer();
|
||||
if (this.digitBuffer) this.digitBuffer.flush();
|
||||
|
||||
/* hang up (or tranfer call) after playing next audio file? */
|
||||
if (intent.saysEndInteraction) {
|
||||
// if 'end_interaction' is true, end the dialog after playing the final prompt
|
||||
// (or in 1 second if there is no final prompt)
|
||||
this.hangupAfterPlayDone = true;
|
||||
this.waitingForPlayStart = true;
|
||||
setTimeout(() => {
|
||||
if (this.waitingForPlayStart) {
|
||||
this.logger.info('hanging up since intent was marked end interaction');
|
||||
this.performAction({dialogflowResult: 'completed'});
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/* collect digits? */
|
||||
else if (intent.saysCollectDtmf || this.enableDtmfAlways) {
|
||||
const opts = Object.assign({
|
||||
idt: this.opts.interDigitTimeout
|
||||
}, intent.dtmfInstructions || {term: '#'});
|
||||
this.digitBuffer = new DigitBuffer(this.logger, opts);
|
||||
this.digitBuffer.once('fulfilled', this._onDtmfEntryComplete.bind(this, ep));
|
||||
}
|
||||
|
||||
/* if we are using tts and a message was provided, play it out */
|
||||
if (this.vendor && intent.fulfillmentText && intent.fulfillmentText.length > 0) {
|
||||
const {srf} = cs;
|
||||
const {stats} = srf.locals;
|
||||
const {synthAudio} = srf.locals.dbHelpers;
|
||||
this.waitingForPlayStart = false;
|
||||
|
||||
// start a new intent, (we want to continue to listen during the audio playback)
|
||||
// _unless_ we are transferring or ending the session
|
||||
if (!this.hangupAfterPlayDone) {
|
||||
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const {filePath} = await this._fallbackSynthAudio(cs, intent, stats, synthAudio);
|
||||
if (filePath) cs.trackTmpFile(filePath);
|
||||
|
||||
if (this.playInProgress) {
|
||||
await ep.api('uuid_break', ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
}
|
||||
this.playInProgress = true;
|
||||
this.curentAudioFile = filePath;
|
||||
|
||||
this.logger.debug(`starting to play tts ${filePath}`);
|
||||
|
||||
if (this.events.includes('start-play')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'start-play', data: {path: filePath}});
|
||||
}
|
||||
await ep.play(filePath);
|
||||
if (this.events.includes('stop-play')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: filePath}});
|
||||
}
|
||||
this.logger.debug(`finished ${filePath}`);
|
||||
|
||||
if (this.curentAudioFile === filePath) {
|
||||
this.playInProgress = false;
|
||||
if (this.queuedTasks) {
|
||||
this.logger.debug('finished playing audio and we have queued tasks');
|
||||
this._redirect(cs, this.queuedTasks);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.greetingPlayed = true;
|
||||
|
||||
if (this.hangupAfterPlayDone) {
|
||||
this.logger.info('hanging up since intent was marked end interaction and we completed final prompt');
|
||||
this.performAction({dialogflowResult: 'completed'});
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
else {
|
||||
// every time we finish playing a prompt, start the no-input timer
|
||||
this._startNoinputTimer(ep, cs);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'Dialogflow:_onIntent - error playing tts');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _fallbackSynthAudio(cs, intent, stats, synthAudio) {
|
||||
try {
|
||||
const obj = {
|
||||
account_sid: cs.accountSid,
|
||||
text: intent.fulfillmentText,
|
||||
vendor: this.vendor,
|
||||
language: this.language,
|
||||
voice: this.voice,
|
||||
salt: cs.callSid,
|
||||
credentials: this.ttsCredentials
|
||||
};
|
||||
this.logger.debug({obj}, 'Dialogflow:_onIntent - playing message via tts');
|
||||
|
||||
return await synthAudio(stats, obj);
|
||||
} catch (error) {
|
||||
this.logger.info({error}, 'Failed to synthesize audio from primary vendor');
|
||||
|
||||
try {
|
||||
if (this.fallbackVendor) {
|
||||
const credentials = cs.getSpeechCredentials(this.fallbackVendor, 'tts', this.fallbackLabel);
|
||||
const obj = {
|
||||
account_sid: cs.accountSid,
|
||||
text: intent.fulfillmentText,
|
||||
vendor: this.fallbackVendor,
|
||||
language: this.fallbackLanguage,
|
||||
voice: this.fallbackVoice,
|
||||
salt: cs.callSid,
|
||||
credentials
|
||||
};
|
||||
this.logger.debug({obj}, 'Dialogflow:_onIntent - playing message via fallback tts');
|
||||
return await synthAudio(stats, obj);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Failed to synthesize audio from falllback vendor');
|
||||
throw err;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A transcription - either interim or final - has been returned.
|
||||
* If we are doing barge-in based on hotword detection, check for the hotword or phrase.
|
||||
* If we are playing a filler sound, like typing, during the fullfillment phase, start that
|
||||
* if this is a final transcript.
|
||||
* @param {*} ep - media server endpoint
|
||||
* @param {*} evt - event data
|
||||
*/
|
||||
async _onTranscription(ep, cs, evt) {
|
||||
const transcription = new Transcription(this.logger, evt);
|
||||
|
||||
if (this.events.includes('transcription') && transcription.isFinal) {
|
||||
this._performHook(cs, this.eventHook, {event: 'transcription', data: evt});
|
||||
}
|
||||
else if (this.events.includes('interim-transcription') && !transcription.isFinal) {
|
||||
this._performHook(cs, this.eventHook, {event: 'transcription', data: evt});
|
||||
}
|
||||
|
||||
// if a final transcription, start a typing sound
|
||||
if (this.thinkingMusic && !transcription.isEmpty && transcription.isFinal &&
|
||||
transcription.confidence > 0.8) {
|
||||
ep.play(this.data.thinkingMusic).catch((err) => this.logger.info(err, 'Error playing typing sound'));
|
||||
}
|
||||
|
||||
// interrupt playback on speaking if bargein = true
|
||||
if (this.bargein && this.playInProgress) {
|
||||
this.logger.debug('terminating playback due to speech bargein');
|
||||
this.playInProgress = false;
|
||||
await ep.api('uuid_break', ep.uuid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller has just finished speaking. No action currently taken.
|
||||
* @param {*} evt - event data
|
||||
*/
|
||||
_onEndOfUtterance(cs, evt) {
|
||||
if (this.events.includes('end-utterance')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'end-utterance'});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialogflow has returned an error of some kind.
|
||||
* @param {*} evt - event data
|
||||
*/
|
||||
_onError(ep, cs, evt) {
|
||||
this.logger.error(`got error: ${JSON.stringify(evt)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio has been received from dialogflow and written to a temporary disk file.
|
||||
* Start playing the audio, after killing any filler sound that might be playing.
|
||||
* When the audio completes, start the no-input timer.
|
||||
* @param {*} ep - media server endpoint
|
||||
* @param {*} evt - event data
|
||||
*/
|
||||
async _onAudioProvided(ep, cs, evt) {
|
||||
|
||||
if (this.vendor) return;
|
||||
|
||||
this.waitingForPlayStart = false;
|
||||
|
||||
// kill filler audio
|
||||
await ep.api('uuid_break', ep.uuid);
|
||||
|
||||
// start a new intent, (we want to continue to listen during the audio playback)
|
||||
// _unless_ we are transferring or ending the session
|
||||
if (/*this.greetingPlayed &&*/ !this.hangupAfterPlayDone) {
|
||||
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang}`);
|
||||
}
|
||||
|
||||
this.playInProgress = true;
|
||||
this.curentAudioFile = evt.path;
|
||||
|
||||
this.logger.info(`starting to play ${evt.path}`);
|
||||
if (this.events.includes('start-play')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'start-play', data: {path: evt.path}});
|
||||
}
|
||||
await ep.play(evt.path);
|
||||
if (this.events.includes('stop-play')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: evt.path}});
|
||||
}
|
||||
this.logger.info(`finished ${evt.path}, queued tasks: ${(this.queuedTasks || []).length}`);
|
||||
|
||||
if (this.curentAudioFile === evt.path) {
|
||||
this.playInProgress = false;
|
||||
if (this.queuedTasks) {
|
||||
this.logger.debug('finished playing audio and we have queued tasks');
|
||||
this._redirect(cs, this.queuedTasks);
|
||||
this.queuedTasks.length = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
/*
|
||||
if (!this.inbound && !this.greetingPlayed) {
|
||||
this.logger.info('finished greeting on outbound call, starting new intent');
|
||||
this.ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang}`);
|
||||
}
|
||||
*/
|
||||
this.greetingPlayed = true;
|
||||
|
||||
if (this.hangupAfterPlayDone) {
|
||||
this.logger.info('hanging up since intent was marked end interaction and we completed final prompt');
|
||||
this.performAction({dialogflowResult: 'completed'});
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
else {
|
||||
// every time we finish playing a prompt, start the no-input timer
|
||||
this._startNoinputTimer(ep, cs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* receive a dmtf entry from the caller.
|
||||
* If we have active dtmf instructions, collect and process accordingly.
|
||||
*/
|
||||
_onDtmf(ep, cs, evt) {
|
||||
if (this.digitBuffer) this.digitBuffer.process(evt.dtmf);
|
||||
if (this.events.includes('dtmf')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'dtmf', data: evt});
|
||||
}
|
||||
}
|
||||
|
||||
_onDtmfEntryComplete(ep, dtmfEntry) {
|
||||
this.logger.info(`collected dtmf entry: ${dtmfEntry}`);
|
||||
this.dtmfEntry = dtmfEntry;
|
||||
this.digitBuffer = null;
|
||||
// if a final transcription, start a typing sound
|
||||
if (this.thinkingMusic) {
|
||||
ep.play(this.thinkingMusic).catch((err) => this.logger.info(err, 'Error playing typing sound'));
|
||||
}
|
||||
|
||||
// kill the current dialogflow, which will result in us getting an immediate intent
|
||||
ep.api('dialogflow_stop', `${ep.uuid}`)
|
||||
.catch((err) => this.logger.info(`dialogflow_stop failed: ${err.message}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* The user has not provided any input for some time.
|
||||
* Set the 'noinput' member to true and kill the current dialogflow.
|
||||
* This will result in us re-prompting with an event indicating no input.
|
||||
* @param {*} ep
|
||||
*/
|
||||
_onNoInput(ep, cs) {
|
||||
this.noinput = true;
|
||||
|
||||
if (this.events.includes('no-input')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'no-input'});
|
||||
}
|
||||
|
||||
// kill the current dialogflow, which will result in us getting an immediate intent
|
||||
ep.api('dialogflow_stop', `${ep.uuid}`)
|
||||
.catch((err) => this.logger.info(`dialogflow_stop failed: ${err.message}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the no-input timer, if it is running
|
||||
*/
|
||||
_clearNoinputTimer() {
|
||||
if (this.noinputTimer) {
|
||||
clearTimeout(this.noinputTimer);
|
||||
this.noinputTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the no-input timer. The duration is set in the configuration file.
|
||||
* @param {*} ep
|
||||
*/
|
||||
_startNoinputTimer(ep, cs) {
|
||||
if (!this.noInputTimeout) return;
|
||||
this._clearNoinputTimer();
|
||||
this.noinputTimer = setTimeout(this._onNoInput.bind(this, ep, cs), this.noInputTimeout);
|
||||
}
|
||||
|
||||
async _performHook(cs, hook, results = {}) {
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
const json = await this.cs.requestor.request('verb:hook', hook,
|
||||
{...results, ...cs.callInfo.toJSON()}, httpHeaders);
|
||||
if (json && Array.isArray(json)) {
|
||||
const makeTask = require('../make_task');
|
||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
if (tasks && tasks.length > 0) {
|
||||
if (this.playInProgress) {
|
||||
this.queuedTasks = tasks;
|
||||
this.logger.info({tasks: tasks},
|
||||
`${this.name} replacing application with ${tasks.length} tasks after play completes`);
|
||||
return;
|
||||
}
|
||||
this._redirect(cs, tasks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_redirect(cs, tasks) {
|
||||
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
|
||||
this.performAction({dialogflowResult: 'redirect'}, false);
|
||||
this.reportedFinalAction = true;
|
||||
cs.replaceApplication(tasks);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Dialogflow;
|
||||
89
lib/tasks/dialogflow/intent.js
Normal file
89
lib/tasks/dialogflow/intent.js
Normal file
@@ -0,0 +1,89 @@
|
||||
class Intent {
|
||||
constructor(logger, evt) {
|
||||
this.logger = logger;
|
||||
this.evt = evt;
|
||||
|
||||
this.logger.debug({evt}, 'intent');
|
||||
this.dtmfRequest = checkIntentForDtmfEntry(logger, evt);
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return this.evt.response_id.length === 0;
|
||||
}
|
||||
|
||||
get fulfillmentText() {
|
||||
return this.evt.query_result.fulfillment_text;
|
||||
}
|
||||
|
||||
get saysEndInteraction() {
|
||||
return this.evt.query_result.intent.end_interaction ;
|
||||
}
|
||||
|
||||
get saysCollectDtmf() {
|
||||
return !!this.dtmfRequest;
|
||||
}
|
||||
|
||||
get dtmfInstructions() {
|
||||
return this.dtmfRequest;
|
||||
}
|
||||
|
||||
get name() {
|
||||
if (!this.isEmpty) return this.evt.query_result.intent.display_name;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
name: this.name,
|
||||
fulfillmentText: this.fulfillmentText
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Intent;
|
||||
|
||||
/**
|
||||
* Parse a returned intent for DTMF entry information
|
||||
* i.e.
|
||||
* allow-dtmf-x-y-z
|
||||
* x = min number of digits
|
||||
* y = optional, max number of digits
|
||||
* z = optional, terminating character
|
||||
* e.g.
|
||||
* allow-dtmf-5 : collect 5 digits
|
||||
* allow-dtmf-1-4 : collect between 1 to 4 (inclusive) digits
|
||||
* allow-dtmf-1-4-# : collect 1-4 digits, terminating if '#' is entered
|
||||
* @param {*} intent - dialogflow intent
|
||||
*/
|
||||
const checkIntentForDtmfEntry = (logger, intent) => {
|
||||
const qr = intent.query_result;
|
||||
if (!qr || !qr.fulfillment_messages || !qr.output_contexts) {
|
||||
logger.info({f: qr.fulfillment_messages, o: qr.output_contexts}, 'no dtmfs');
|
||||
return;
|
||||
}
|
||||
|
||||
// check for custom payloads with a gather verb
|
||||
const custom = qr.fulfillment_messages.find((f) => f.payload && f.payload.verb === 'gather');
|
||||
if (custom && custom.payload && custom.payload.verb === 'gather') {
|
||||
logger.info({custom}, 'found dtmf custom payload');
|
||||
return {
|
||||
max: custom.payload.numDigits,
|
||||
term: custom.payload.finishOnKey,
|
||||
template: custom.payload.responseTemplate
|
||||
};
|
||||
}
|
||||
|
||||
// check for an output context with a specific naming convention
|
||||
const context = qr.output_contexts.find((oc) => oc.name.includes('/contexts/allow-dtmf-'));
|
||||
if (context) {
|
||||
const arr = /allow-dtmf-(\d+)(?:-(\d+))?(?:-(.*))?/.exec(context.name);
|
||||
if (arr) {
|
||||
logger.info({custom}, 'found dtmf output context');
|
||||
return {
|
||||
min: parseInt(arr[1]),
|
||||
max: arr.length > 2 ? parseInt(arr[2]) : null,
|
||||
term: arr.length > 3 ? arr[3] : null
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
41
lib/tasks/dialogflow/transcription.js
Normal file
41
lib/tasks/dialogflow/transcription.js
Normal file
@@ -0,0 +1,41 @@
|
||||
class Transcription {
|
||||
constructor(logger, evt) {
|
||||
this.logger = logger;
|
||||
|
||||
this.recognition_result = evt.recognition_result;
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return !this.recognition_result;
|
||||
}
|
||||
|
||||
get isFinal() {
|
||||
return this.recognition_result && this.recognition_result.is_final === true;
|
||||
}
|
||||
|
||||
get confidence() {
|
||||
if (!this.isEmpty) return this.recognition_result.confidence;
|
||||
}
|
||||
|
||||
get text() {
|
||||
if (!this.isEmpty) return this.recognition_result.transcript;
|
||||
}
|
||||
|
||||
startsWith(str) {
|
||||
return (this.text.toLowerCase() || '').startsWith(str.toLowerCase());
|
||||
}
|
||||
|
||||
includes(str) {
|
||||
return (this.text.toLowerCase() || '').includes(str.toLowerCase());
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
final: this.recognition_result.is_final === true,
|
||||
text: this.text,
|
||||
confidence: this.confidence
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Transcription;
|
||||
41
lib/tasks/dtmf.js
Normal file
41
lib/tasks/dtmf.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
|
||||
class TaskDtmf extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
this.dtmf = this.data.dtmf;
|
||||
this.duration = this.data.duration || 500;
|
||||
}
|
||||
|
||||
get name() { return TaskName.Dtmf; }
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
this.ep = ep;
|
||||
try {
|
||||
this.logger.info({data: this.data}, `sending dtmf ${this.dtmf}`);
|
||||
await this.ep.execute('send_dtmf', `${this.dtmf}@${this.duration}`);
|
||||
this.timer = setTimeout(this.notifyTaskDone.bind(this), this.dtmf.length * (this.duration + 250) + 750);
|
||||
await this.awaitTaskDone();
|
||||
this.logger.info({data: this.data}, `done sending dtmf ${this.dtmf}`);
|
||||
} catch (err) {
|
||||
this.logger.info(err, `TaskDtmf:exec - error playing ${this.dtmf}`);
|
||||
}
|
||||
this.emit('playDone');
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
if (this.ep.connected && !this.playComplete) {
|
||||
this.logger.debug('TaskDtmf:kill - killing audio');
|
||||
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
}
|
||||
clearTimeout(this.timer);
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskDtmf;
|
||||
144
lib/tasks/dub.js
Normal file
144
lib/tasks/dub.js
Normal file
@@ -0,0 +1,144 @@
|
||||
const {TaskName} = require('../utils/constants');
|
||||
const TtsTask = require('./tts-task');
|
||||
const assert = require('assert');
|
||||
const parseDecibels = require('../utils/parse-decibels');
|
||||
|
||||
/**
|
||||
* Dub task: add or remove additional audio tracks into the call
|
||||
*/
|
||||
class TaskDub extends TtsTask {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts, parentTask);
|
||||
|
||||
this.logger.debug({opts: this.data}, 'TaskDub constructor');
|
||||
['action', 'track', 'play', 'say', 'loop'].forEach((prop) => {
|
||||
this[prop] = this.data[prop];
|
||||
});
|
||||
this.gain = parseDecibels(this.data.gain);
|
||||
|
||||
assert.ok(this.action, 'TaskDub: action is required');
|
||||
assert.ok(this.track, 'TaskDub: track is required');
|
||||
}
|
||||
|
||||
get name() { return TaskName.Dub; }
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
super.exec(cs);
|
||||
|
||||
try {
|
||||
switch (this.action) {
|
||||
case 'addTrack':
|
||||
await this._addTrack(cs, ep);
|
||||
break;
|
||||
case 'removeTrack':
|
||||
await this._removeTrack(cs, ep);
|
||||
break;
|
||||
case 'silenceTrack':
|
||||
await this._silenceTrack(cs, ep);
|
||||
break;
|
||||
case 'playOnTrack':
|
||||
await this._playOnTrack(cs, ep);
|
||||
break;
|
||||
case 'sayOnTrack':
|
||||
await this._sayOnTrack(cs, ep);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`TaskDub: unsupported action ${this.action}`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'Error executing dub task');
|
||||
}
|
||||
}
|
||||
|
||||
async _addTrack(cs, ep) {
|
||||
this.logger.info(`adding track: ${this.track}`);
|
||||
await ep.dub({
|
||||
action: 'addTrack',
|
||||
track: this.track
|
||||
});
|
||||
|
||||
if (this.play) await this._playOnTrack(cs, ep);
|
||||
else if (this.say) await this._sayOnTrack(cs, ep);
|
||||
}
|
||||
|
||||
async _removeTrack(_cs, ep) {
|
||||
this.logger.info(`removing track: ${this.track}`);
|
||||
await ep.dub({
|
||||
action: 'removeTrack',
|
||||
track: this.track
|
||||
});
|
||||
}
|
||||
|
||||
async _silenceTrack(_cs, ep) {
|
||||
this.logger.info(`silencing track: ${this.track}`);
|
||||
await ep.dub({
|
||||
action: 'silenceTrack',
|
||||
track: this.track
|
||||
});
|
||||
}
|
||||
|
||||
async _playOnTrack(_cs, ep) {
|
||||
this.logger.info(`playing on track: ${this.track}`);
|
||||
await ep.dub({
|
||||
action: 'playOnTrack',
|
||||
track: this.track,
|
||||
play: this.play,
|
||||
loop: this.loop ? 'loop' : 'once',
|
||||
gain: this.gain
|
||||
});
|
||||
}
|
||||
|
||||
async _sayOnTrack(cs, ep) {
|
||||
const text = this.say.text || this.say;
|
||||
this.synthesizer = this.say.synthesizer || {};
|
||||
|
||||
if (Object.keys(this.synthesizer).length) {
|
||||
this.logger.info({synthesizer: this.synthesizer},
|
||||
`saying on track ${this.track}: ${text} with synthesizer options`);
|
||||
}
|
||||
else {
|
||||
this.logger.info(`saying on track ${this.track}: ${text}`);
|
||||
}
|
||||
this.synthesizer = this.synthesizer || {};
|
||||
|
||||
this.text = [text];
|
||||
|
||||
const vendor = this.synthesizer.vendor && this.synthesizer.vendor !== 'default' ?
|
||||
this.synthesizer.vendor :
|
||||
cs.speechSynthesisVendor;
|
||||
const language = this.synthesizer.language && this.synthesizer.language !== 'default' ?
|
||||
this.synthesizer.language :
|
||||
cs.speechSynthesisLanguage ;
|
||||
const voice = this.synthesizer.voice && this.synthesizer.voice !== 'default' ?
|
||||
this.synthesizer.voice :
|
||||
cs.speechSynthesisVoice;
|
||||
const label = this.synthesizer.label && this.synthesizer.label !== 'default' ?
|
||||
this.synthesizer.label :
|
||||
cs.speechSynthesisLabel;
|
||||
|
||||
const disableTtsStreaming = false;
|
||||
const filepath = await this._synthesizeWithSpecificVendor(cs, ep, {
|
||||
vendor, language, voice, label, disableTtsStreaming
|
||||
});
|
||||
assert.ok(filepath.length === 1, 'TaskDub: no filepath returned from synthesizer');
|
||||
|
||||
const path = filepath[0];
|
||||
if (!path.startsWith('say:{')) {
|
||||
/* we have a local file of mp3 or r8 of synthesized speech audio to play */
|
||||
this.logger.info(`playing synthesized speech from file on track ${this.track}: ${path}`);
|
||||
this.play = path;
|
||||
await this._playOnTrack(cs, ep);
|
||||
}
|
||||
else {
|
||||
this.logger.info(`doing actual text to speech file on track ${this.track}: ${path}`);
|
||||
await ep.dub({
|
||||
action: 'sayOnTrack',
|
||||
track: this.track,
|
||||
say: path,
|
||||
gain: this.gain
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskDub;
|
||||
386
lib/tasks/enqueue.js
Normal file
386
lib/tasks/enqueue.js
Normal file
@@ -0,0 +1,386 @@
|
||||
const Task = require('./task');
|
||||
const Emitter = require('events');
|
||||
const ConfirmCallSession = require('../session/confirm-call-session');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const makeTask = require('./make_task');
|
||||
const {TaskName, TaskPreconditions, QueueResults, KillReason} = require('../utils/constants');
|
||||
const bent = require('bent');
|
||||
const assert = require('assert');
|
||||
|
||||
const getUrl = (cs) => `${cs.srf.locals.serviceUrl}/v1/enqueue/${cs.callSid}`;
|
||||
|
||||
const getElapsedTime = (from) => Math.floor((Date.now() - from) / 1000);
|
||||
|
||||
class TaskEnqueue extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.logger = logger;
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
this.queueName = this.data.name;
|
||||
this.priority = this.data.priority;
|
||||
this.waitHook = this.data.waitHook;
|
||||
|
||||
this.emitter = new Emitter();
|
||||
this.state = QueueResults.Wait;
|
||||
|
||||
// transferred from another server in order to bridge to a local caller?
|
||||
if (this.data._) {
|
||||
this.bridgeNow = true;
|
||||
this.bridgeDetails = {
|
||||
epUid: this.data._.epUuid,
|
||||
notifyUrl: this.data._.notifyUrl
|
||||
};
|
||||
this.waitStartTime = this.data._.waitStartTime;
|
||||
this.connectTime = this.data._.connectTime;
|
||||
}
|
||||
}
|
||||
|
||||
get name() { return TaskName.Enqueue; }
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
const dlg = cs.dlg;
|
||||
this.queueName = `queue:${cs.accountSid}:${this.queueName}`;
|
||||
|
||||
try {
|
||||
if (!this.bridgeNow) {
|
||||
await this._addToQueue(cs, dlg, ep);
|
||||
await this._doWait(cs, dlg, ep);
|
||||
}
|
||||
else {
|
||||
// update dialog's answer time to when it was answered on the previous server, not now
|
||||
dlg.connectTime = this.connectTime;
|
||||
await this._doBridge(cs, dlg, ep);
|
||||
}
|
||||
if (!this.callMoved) await this.performAction();
|
||||
await this.awaitTaskDone();
|
||||
|
||||
this.logger.debug(`TaskEnqueue:exec - task done queue ${this.queueName}`);
|
||||
} catch (err) {
|
||||
this.logger.info(err, `TaskEnqueue:exec - error in enqueue ${this.queueName}`);
|
||||
}
|
||||
}
|
||||
|
||||
async kill(cs, reason) {
|
||||
super.kill(cs);
|
||||
this.killReason = reason || KillReason.Hangup;
|
||||
this.logger.info(`TaskEnqueue:kill ${this.queueName} with reason ${this.killReason}`);
|
||||
this.emitter.emit('kill', reason || KillReason.Hangup);
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
async _addToQueue(cs, dlg) {
|
||||
const {addToSortedSet, sortedSetLength} = cs.srf.locals.dbHelpers;
|
||||
const url = getUrl(cs);
|
||||
this.waitStartTime = Date.now();
|
||||
this.logger.debug({queue: this.queueName, url}, 'pushing url onto queue');
|
||||
if (this.priority < 0) {
|
||||
this.logger.warn(`priority ${this.priority} is invalid, need to be non-negative integer,
|
||||
999 will be used for priority`);
|
||||
}
|
||||
let members = await addToSortedSet(this.queueName, url, this.priority);
|
||||
if (members === 1) {
|
||||
this.logger.info('TaskEnqueue:_addToQueue: added to queue');
|
||||
} else {
|
||||
this.logger.info('TaskEnqueue:_addToQueue: failed to add to queue');
|
||||
}
|
||||
members = await sortedSetLength(this.queueName);
|
||||
|
||||
this.notifyUrl = url;
|
||||
|
||||
/* invoke account-level webhook for queue event notifications */
|
||||
try {
|
||||
cs.performQueueWebhook({
|
||||
event: 'join',
|
||||
queue: this.data.name,
|
||||
length: members,
|
||||
joinTime: this.waitStartTime
|
||||
});
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
async _removeFromQueue(cs) {
|
||||
const {retrieveByPatternSortedSet, sortedSetLength} = cs.srf.locals.dbHelpers;
|
||||
await retrieveByPatternSortedSet(this.queueName, `*${getUrl(cs)}`);
|
||||
return await sortedSetLength(this.queueName);
|
||||
}
|
||||
|
||||
async performAction() {
|
||||
const params = {
|
||||
queueSid: this.queueName,
|
||||
queueTime: getElapsedTime(this.waitStartTime),
|
||||
queueResult: this.state
|
||||
};
|
||||
await super.performAction(params, this.killReason !== KillReason.Replaced);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add ourselves to the queue with a url that can be invoked to tell us to dequeue
|
||||
* @param {CallSession} cs
|
||||
* @param {SipDialog} dlg
|
||||
*/
|
||||
async _doWait(cs, dlg, ep) {
|
||||
return new Promise(async(resolve, reject) => {
|
||||
this.emitter
|
||||
.once('dequeue', (opts) => {
|
||||
this.bridgeDetails = opts;
|
||||
this.logger.info({bridgeDetails: this.bridgeDetails}, `time to dequeue from ${this.queueName}`);
|
||||
if (this._playSession) {
|
||||
this._leave = false;
|
||||
this._playSession.kill();
|
||||
this._playSession = null;
|
||||
}
|
||||
resolve(this._doBridge(cs, dlg, ep));
|
||||
})
|
||||
.once('kill', async() => {
|
||||
|
||||
/* invoke account-level webhook for queue event notifications */
|
||||
if (!this.dequeued) {
|
||||
try {
|
||||
const members = await this._removeFromQueue(cs);
|
||||
cs.performQueueWebhook({
|
||||
event: 'leave',
|
||||
queue: this.data.name,
|
||||
length: members,
|
||||
leaveReason: 'abandoned',
|
||||
leaveTime: Date.now()
|
||||
});
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
if (this._playSession) {
|
||||
this.logger.debug('killing waitUrl');
|
||||
this._playSession.kill();
|
||||
this._playSession = null;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
|
||||
if (this.waitHook && !this.killed) {
|
||||
do {
|
||||
try {
|
||||
await ep.play('silence_stream://500');
|
||||
const tasks = await this._playHook(cs, dlg, this.waitHook);
|
||||
if (0 === tasks.length) break;
|
||||
} catch (err) {
|
||||
if (!this.bridgeDetails && !this.killed) {
|
||||
this.logger.info(err, `TaskEnqueue:_doWait: failed retrieving waitHook for ${this.queueName}`);
|
||||
}
|
||||
this._playSession = null;
|
||||
break;
|
||||
}
|
||||
} while (!this.killed && !this.bridgeDetails);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge to another call.
|
||||
* The call may be homed on this feature server, or another one -
|
||||
* in the latter case, move the call to the other server via REFER
|
||||
* Returns a promise that resolves:
|
||||
* (a) When the call is transferred to the other feature server if the dequeue-er is not local, or
|
||||
* (b) When either party hangs up the bridged call
|
||||
* @param {CallSession} cs
|
||||
* @param {SipDialog} dlg
|
||||
*/
|
||||
async _doBridge(cs, dlg, ep) {
|
||||
assert(this.bridgeNow || this.bridgeDetails.dequeueSipAddress);
|
||||
if (!this.bridgeNow && cs.srf.locals.localSipAddress !== this.bridgeDetails.dequeueSipAddress) {
|
||||
this.logger.info({
|
||||
localServer: cs.srf.locals.localSipAddress,
|
||||
otherServer: this.bridgeDetails.dequeueSipAddress
|
||||
}, `TaskEnqueue:_doBridge: leg for queue ${this.queueName} is hosted elsewhere`);
|
||||
const success = await this.transferCallToFeatureServer(cs, this.bridgeDetails.dequeueSipAddress, {
|
||||
waitStartTime: this.waitStartTime,
|
||||
epUuid: this.bridgeDetails.epUuid,
|
||||
notifyUrl: this.bridgeDetails.notifyUrl,
|
||||
connectTime: dlg.connectTime.valueOf()
|
||||
});
|
||||
|
||||
/**
|
||||
* If the REFER succeeded, we will get a BYE from the SBC
|
||||
* which will trigger kill and the end of the execution of the CallSession
|
||||
* which is what we want - so do nothing and let that happen.
|
||||
* If on the other hand, the REFER failed then we are in a bad state
|
||||
* and need to end the enqueue task with a failure indication and
|
||||
* allow the application to continue on
|
||||
*/
|
||||
if (success) {
|
||||
this.logger.info(`TaskEnqueue:_doBridge: REFER of ${this.queueName} succeeded`);
|
||||
return;
|
||||
}
|
||||
this.state = QueueResults.Error;
|
||||
this.notifyTaskDone();
|
||||
return;
|
||||
}
|
||||
this.logger.info(`TaskEnqueue:_doBridge: queue ${this.queueName} is hosted locally`);
|
||||
await this._bridgeLocal(cs, dlg, ep);
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
_bridgeLocal(cs, dlg, ep) {
|
||||
assert(this.bridgeDetails.notifyUrl);
|
||||
|
||||
return new Promise(async(resolve, reject) => {
|
||||
try {
|
||||
|
||||
// notify partner we are ready to be bridged - giving him our possibly new url and endpoint
|
||||
const notifyUrl = getUrl(cs);
|
||||
const url = this.bridgeDetails.notifyUrl;
|
||||
|
||||
this.logger.debug('TaskEnqueue:_doBridge: ready to be bridged');
|
||||
bent('POST', 202)(url, {
|
||||
event: 'ready',
|
||||
epUuid: ep.uuid,
|
||||
notifyUrl
|
||||
}).catch((err) => {
|
||||
this.logger.info({err, url}, 'TaskEnqueue:_bridgeLocal error sending bridged event');
|
||||
/**
|
||||
* TODO: this probably means he dropped while we were connecting....
|
||||
* should we put this call back to the front of the queue so he gets serviced (?)
|
||||
*/
|
||||
this.state = QueueResults.Error;
|
||||
reject(new Error('bridge failure'));
|
||||
});
|
||||
|
||||
// resolve when either side hangs up
|
||||
this.state = QueueResults.Bridged;
|
||||
this.emitter
|
||||
.on('hangup', () => {
|
||||
this.logger.info('TaskEnqueue:_bridgeLocal ending with hangup from dequeue party');
|
||||
ep.unbridge().catch((err) => {});
|
||||
resolve();
|
||||
})
|
||||
.on('kill', (reason) => {
|
||||
this.killReason = reason;
|
||||
this.logger.info(`TaskEnqueue:_bridgeLocal ending with ${this.killReason}`);
|
||||
ep.unbridge().catch((err) => {});
|
||||
|
||||
// notify partner that we dropped
|
||||
bent('POST', 202)(this.bridgeDetails.notifyUrl, {event: 'hangup'}).catch((err) => {
|
||||
this.logger.info(err, 'TaskEnqueue:_bridgeLocal error sending hangup event to partner');
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
this.state = QueueResults.Error;
|
||||
this.logger.error(err, 'TaskEnqueue:_bridgeLocal error');
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* We are being dequeued and bridged to another call.
|
||||
* It may be on this server or a different one, and we are
|
||||
* given instructions how to find it and connect.
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.epUuid uuid of the endpoint we need to bridge to
|
||||
* @param {string} opts.dequeueSipAddress ip:port of the feature server hosting the other call
|
||||
*/
|
||||
async notifyQueueEvent(cs, opts) {
|
||||
if (opts.event === 'dequeue') {
|
||||
if (this.bridgeNow) return;
|
||||
this.logger.info({opts}, `TaskEnqueue:notifyDequeueEvent: leaving ${this.queueName} because someone wants me`);
|
||||
assert(opts.dequeueSipAddress && opts.epUuid && opts.notifyUrl);
|
||||
this.emitter.emit('dequeue', opts);
|
||||
|
||||
try {
|
||||
const {sortedSetLength} = cs.srf.locals.dbHelpers;
|
||||
const members = await sortedSetLength(this.queueName);
|
||||
this.dequeued = true;
|
||||
cs.performQueueWebhook({
|
||||
event: 'leave',
|
||||
queue: this.data.name,
|
||||
length: Math.max(members, 0),
|
||||
leaveReason: 'dequeued',
|
||||
leaveTime: Date.now(),
|
||||
dequeuer: opts.dequeuer
|
||||
});
|
||||
} catch (err) {}
|
||||
}
|
||||
else if (opts.event === 'hangup') {
|
||||
this.emitter.emit('hangup');
|
||||
}
|
||||
else {
|
||||
this.logger.error({opts}, 'TaskEnqueue:notifyDequeueEvent - unsupported event/payload');
|
||||
}
|
||||
}
|
||||
|
||||
async _playHook(cs, dlg, hook,
|
||||
allowed = [TaskName.Play, TaskName.Say, TaskName.Pause, TaskName.Leave, TaskName.Tag]) {
|
||||
const {sortedSetLength, sortedSetPositionByPattern} = cs.srf.locals.dbHelpers;
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
|
||||
assert(!this._playSession);
|
||||
if (this.killed) return [];
|
||||
|
||||
const params = {
|
||||
queueSid: this.queueName,
|
||||
queueTime: getElapsedTime(this.waitStartTime)
|
||||
};
|
||||
try {
|
||||
const queueSize = await sortedSetLength(this.queueName);
|
||||
const queuePosition = await sortedSetPositionByPattern(this.queueName, `*${this.notifyUrl}`);
|
||||
Object.assign(params, {
|
||||
queueSize,
|
||||
queuePosition: queuePosition.length ? queuePosition[0] : 0,
|
||||
callSid: this.cs.callSid,
|
||||
callId: this.cs.callId,
|
||||
customerData: this.cs.callInfo.customerData
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error({err}, `TaskEnqueue:_playHook error retrieving list info for queue ${this.queueName}`);
|
||||
}
|
||||
const json = await cs.application.requestor.request('verb:hook', hook, params, httpHeaders);
|
||||
this.logger.debug({json}, 'TaskEnqueue:_playHook: received response from waitHook');
|
||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
|
||||
const allowedTasks = tasks.filter((t) => allowed.includes(t.name));
|
||||
if (tasks.length !== allowedTasks.length) {
|
||||
this.logger.debug({tasks, allowedTasks}, 'unsupported task');
|
||||
throw new Error(`unsupported verb in enqueue waitHook: only ${JSON.stringify(allowed)}`);
|
||||
}
|
||||
this.logger.debug(`TaskEnqueue:_playHook: executing ${tasks.length} tasks`);
|
||||
|
||||
// check for 'leave' verb and only execute tasks up till then
|
||||
const tasksToRun = [];
|
||||
for (const o of tasks) {
|
||||
if (o.name === TaskName.Leave) {
|
||||
this._leave = true;
|
||||
this.logger.info('waitHook returned a leave task');
|
||||
break;
|
||||
}
|
||||
tasksToRun.push(o);
|
||||
}
|
||||
const cloneTasks = [...tasksToRun];
|
||||
if (this.killed) return [];
|
||||
else if (tasksToRun.length > 0) {
|
||||
this._playSession = new ConfirmCallSession({
|
||||
logger: this.logger,
|
||||
application: cs.application,
|
||||
dlg,
|
||||
ep: cs.ep,
|
||||
callInfo: cs.callInfo,
|
||||
accountInfo: cs.accountInfo,
|
||||
tasks: tasksToRun,
|
||||
rootSpan: cs.rootSpan,
|
||||
req: cs.req
|
||||
});
|
||||
await this._playSession.exec();
|
||||
this._playSession = null;
|
||||
}
|
||||
if (this._leave) {
|
||||
this.state = QueueResults.Leave;
|
||||
this.kill(cs);
|
||||
}
|
||||
return cloneTasks;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskEnqueue;
|
||||
1322
lib/tasks/gather.js
1322
lib/tasks/gather.js
File diff suppressed because it is too large
Load Diff
@@ -14,10 +14,11 @@ class TaskHangup extends Task {
|
||||
/**
|
||||
* Hangup the call
|
||||
*/
|
||||
async exec(cs, dlg) {
|
||||
super.exec(cs);
|
||||
async exec(cs, {dlg}) {
|
||||
await super.exec(cs);
|
||||
try {
|
||||
await dlg.destroy({headers: this.headers});
|
||||
cs._callReleased();
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'TaskHangup:exec - Error hanging up call');
|
||||
}
|
||||
|
||||
22
lib/tasks/leave.js
Normal file
22
lib/tasks/leave.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName} = require('../utils/constants');
|
||||
|
||||
class TaskLeave extends Task {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts);
|
||||
}
|
||||
|
||||
get name() { return TaskName.Leave; }
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
await this.awaitTaskDone();
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskLeave;
|
||||
350
lib/tasks/lex.js
Normal file
350
lib/tasks/lex.js
Normal file
@@ -0,0 +1,350 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
|
||||
class Lex extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
if (this.data.credentials) {
|
||||
this.awsAccessKeyId = this.data.credentials.accessKey;
|
||||
this.awsSecretAccessKey = this.data.credentials.secretAccessKey;
|
||||
}
|
||||
this.bot = this.data.botId;
|
||||
this.alias = this.data.botAlias;
|
||||
this.region = this.data.region;
|
||||
this.locale = this.data.locale || 'en_US';
|
||||
this.intent = this.data.intent || {};
|
||||
this.metadata = this.data.metadata;
|
||||
this.welcomeMessage = this.data.welcomeMessage;
|
||||
this.bargein = this.data.bargein || false;
|
||||
this.passDtmf = this.data.passDtmf || false;
|
||||
if (this.data.noInputTimeout) this.noInputTimeout = this.data.noInputTimeout * 1000;
|
||||
if (this.data.tts) {
|
||||
this.vendor = this.data.tts.vendor || 'default';
|
||||
this.language = this.data.tts.language || 'default';
|
||||
this.voice = this.data.tts.voice || 'default';
|
||||
this.speechCredentialLabel = this.data.tts.label || 'default';
|
||||
|
||||
// fallback tts
|
||||
this.fallbackVendor = this.data.tts.fallbackVendor || 'default';
|
||||
this.fallbackLanguage = this.data.tts.fallbackLanguage || 'default';
|
||||
this.fallbackVoice = this.data.tts.fallbackLanguage || 'default';
|
||||
this.fallbackLabel = this.data.tts.fallbackLabel || 'default';
|
||||
}
|
||||
|
||||
this.botName = `${this.bot}:${this.alias}:${this.region}`;
|
||||
if (this.data.eventHook) this.eventHook = this.data.eventHook;
|
||||
this.events = this.eventHook ?
|
||||
[
|
||||
'intent',
|
||||
'transcription',
|
||||
'dtmf',
|
||||
'start-play',
|
||||
'stop-play',
|
||||
'play-interrupted',
|
||||
'response-text'
|
||||
] : [];
|
||||
if (this.data.actionHook) this.actionHook = this.data.actionHook;
|
||||
}
|
||||
|
||||
get name() { return TaskName.Lex; }
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
|
||||
try {
|
||||
await this.init(cs, ep);
|
||||
|
||||
// kick it off
|
||||
const obj = {};
|
||||
let cmd = `${this.ep.uuid} ${this.bot} ${this.alias} ${this.region} ${this.locale} `;
|
||||
|
||||
if (this.metadata) Object.assign(obj, this.metadata);
|
||||
if (this.intent.name) {
|
||||
cmd += this.intent.name;
|
||||
if (this.intent.slots) Object.assign(obj, {slots: this.intent.slots});
|
||||
}
|
||||
|
||||
if (Object.keys(obj).length > 0) cmd += ` '${JSON.stringify(obj)}'`;
|
||||
|
||||
this.logger.debug({cmd}, `starting lex bot ${this.botName} with locale ${this.locale}`);
|
||||
this.ep.api('aws_lex_start', cmd)
|
||||
.catch((err) => {
|
||||
this.logger.error({err}, `Error starting lex bot ${this.botName}`);
|
||||
this.notifyTaskDone();
|
||||
});
|
||||
|
||||
await this.awaitTaskDone();
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'Lex:exec error');
|
||||
}
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
if (this.ep.connected) {
|
||||
this.logger.debug('Lex:kill');
|
||||
this.ep.removeCustomEventListener('lex::intent');
|
||||
this.ep.removeCustomEventListener('lex::transcription');
|
||||
this.ep.removeCustomEventListener('lex::audio_provided');
|
||||
this.ep.removeCustomEventListener('lex::text_response');
|
||||
this.ep.removeCustomEventListener('lex::playback_interruption');
|
||||
this.ep.removeCustomEventListener('lex::error');
|
||||
this.ep.removeAllListeners('dtmf');
|
||||
|
||||
this.performAction({lexResult: 'caller hungup'})
|
||||
.catch((err) => this.logger.error({err}, 'lex - error w/ action webook'));
|
||||
|
||||
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
}
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
async init(cs, ep) {
|
||||
this.ep = ep;
|
||||
try {
|
||||
if (this.vendor === 'default') {
|
||||
this.vendor = cs.speechSynthesisVendor;
|
||||
this.language = cs.speechSynthesisLanguage;
|
||||
this.voice = cs.speechSynthesisVoice;
|
||||
this.speechCredentialLabel = cs.speechSynthesisLabel;
|
||||
}
|
||||
if (this.fallbackVendor === 'default') {
|
||||
this.fallbackVendor = cs.fallbackSpeechSynthesisVendor;
|
||||
this.fallbackLanguage = cs.fallbackSpeechSynthesisLanguage;
|
||||
this.fallbackVoice = cs.fallbackSpeechSynthesisVoice;
|
||||
this.fallbackLabel = cs.fallbackSpeechSynthesisLabel;
|
||||
}
|
||||
|
||||
this.ttsCredentials = cs.getSpeechCredentials(this.vendor, 'tts', this.speechCredentialLabel);
|
||||
|
||||
this.ep.addCustomEventListener('lex::intent', this._onIntent.bind(this, ep, cs));
|
||||
this.ep.addCustomEventListener('lex::transcription', this._onTranscription.bind(this, ep, cs));
|
||||
this.ep.addCustomEventListener('lex::audio_provided', this._onAudioProvided.bind(this, ep, cs));
|
||||
this.ep.addCustomEventListener('lex::text_response', this._onTextResponse.bind(this, ep, cs));
|
||||
this.ep.addCustomEventListener('lex::playback_interruption', this._onPlaybackInterruption.bind(this, ep, cs));
|
||||
this.ep.addCustomEventListener('lex::error', this._onError.bind(this, ep, cs));
|
||||
this.ep.on('dtmf', this._onDtmf.bind(this, ep, cs));
|
||||
|
||||
const channelVars = {};
|
||||
if (this.bargein) {
|
||||
Object.assign(channelVars, {'x-amz-lex:barge-in-enabled': 1});
|
||||
}
|
||||
if (this.noInputTimeout) {
|
||||
Object.assign(channelVars, {'x-amz-lex:audio:start-timeout-ms': this.noInputTimeout});
|
||||
}
|
||||
if (this.awsAccessKeyId && this.awsSecretAccessKey) {
|
||||
Object.assign(channelVars, {
|
||||
AWS_ACCESS_KEY_ID: this.awsAccessKeyId,
|
||||
AWS_SECRET_ACCESS_KEY: this.awsSecretAccessKey
|
||||
});
|
||||
}
|
||||
if (this.vendor) Object.assign(channelVars, {LEX_USE_TTS: 1});
|
||||
//if (this.intent.name) Object.assign(channelVars, {LEX_WELCOME_INTENT: this.intent});
|
||||
if (this.welcomeMessage && this.welcomeMessage.length) {
|
||||
Object.assign(channelVars, {LEX_WELCOME_MESSAGE: this.welcomeMessage});
|
||||
}
|
||||
if (Object.keys(channelVars).length) await this.ep.set(channelVars);
|
||||
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'Error setting listeners');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An intent has been returned.
|
||||
* we may get an empty intent, signified by ...
|
||||
* In such a case, we just restart the bot.
|
||||
* @param {*} ep - media server endpoint
|
||||
* @param {*} evt - event data
|
||||
*/
|
||||
_onIntent(ep, cs, evt) {
|
||||
this.logger.debug({evt}, `got intent for ${this.botName}`);
|
||||
if (this.events.includes('intent')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'intent', data: evt});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A transcription - either interim or final - has been returned.
|
||||
* If we are doing barge-in based on hotword detection, check for the hotword or phrase.
|
||||
* If we are playing a filler sound, like typing, during the fullfillment phase, start that
|
||||
* if this is a final transcript.
|
||||
* @param {*} ep - media server endpoint
|
||||
* @param {*} evt - event data
|
||||
*/
|
||||
_onTranscription(ep, cs, evt) {
|
||||
this.logger.debug({evt}, `got transcription for ${this.botName}`);
|
||||
if (this.events.includes('transcription')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'transcription', data: evt});
|
||||
}
|
||||
}
|
||||
|
||||
async _fallbackSynthAudio(cs, msg, stats, synthAudio) {
|
||||
try {
|
||||
const {filePath} = await synthAudio(stats, {
|
||||
account_sid: cs.accountSid,
|
||||
text: msg,
|
||||
vendor: this.vendor,
|
||||
language: this.language,
|
||||
voice: this.voice,
|
||||
salt: cs.callSid,
|
||||
credentials: this.ttsCredentials
|
||||
});
|
||||
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
this.logger.info({error}, 'failed to synth audio from primary vendor');
|
||||
if (this.fallbackVendor) {
|
||||
try {
|
||||
const credential = cs.getSpeechCredentials(this.fallbackVendor, 'tts', this.fallbackLabel);
|
||||
const {filePath} = await synthAudio(stats, {
|
||||
account_sid: cs.accountSid,
|
||||
text: msg,
|
||||
vendor: this.fallbackVendor,
|
||||
language: this.fallbackLanguage,
|
||||
voice: this.fallbackVoice,
|
||||
salt: cs.callSid,
|
||||
credentials: credential
|
||||
});
|
||||
return filePath;
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'failed to synth audio from fallback vendor');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {*} evt - event data
|
||||
*/
|
||||
async _onTextResponse(ep, cs, evt) {
|
||||
this.logger.debug({evt}, `got text response for ${this.botName}`);
|
||||
const messages = evt.messages;
|
||||
if (this.events.includes('response-text')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'response-text', data: evt});
|
||||
}
|
||||
if (this.vendor && Array.isArray(messages) && messages.length) {
|
||||
const msg = messages[0].msg;
|
||||
const type = messages[0].type;
|
||||
if (['PlainText', 'SSML'].includes(type) && msg) {
|
||||
const {srf} = cs;
|
||||
const {stats} = srf.locals;
|
||||
const {synthAudio} = srf.locals.dbHelpers;
|
||||
|
||||
try {
|
||||
this.logger.debug(`tts with ${this.vendor} ${this.voice}`);
|
||||
const filePath = await this._fallbackSynthAudio(cs, msg, stats, synthAudio);
|
||||
if (filePath) cs.trackTmpFile(filePath);
|
||||
|
||||
if (this.events.includes('start-play')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'start-play', data: {path: filePath}});
|
||||
}
|
||||
await ep.play(filePath);
|
||||
if (this.events.includes('stop-play')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: filePath}});
|
||||
}
|
||||
this.logger.debug(`finished tts, sending play_done ${this.vendor} ${this.voice}`);
|
||||
this.ep.api('aws_lex_play_done', this.ep.uuid)
|
||||
.catch((err) => {
|
||||
this.logger.error({err}, `Error sending play_done ${this.botName}`);
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'Lex:_onTextResponse - error playing tts');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {*} evt - event data
|
||||
*/
|
||||
_onPlaybackInterruption(ep, cs, evt) {
|
||||
this.logger.debug({evt}, `got playback interruption for ${this.botName}`);
|
||||
if (this.bargein) {
|
||||
if (this.events.includes('play-interrupted')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'play-interrupted', data: {}});
|
||||
}
|
||||
this.ep.api('uuid_break', this.ep.uuid)
|
||||
.catch((err) => this.logger.info(err, 'Lex::_onPlaybackInterruption - Error killing audio'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lex has returned an error of some kind.
|
||||
* @param {*} evt - event data
|
||||
*/
|
||||
_onError(ep, cs, evt) {
|
||||
this.logger.error({evt}, `got error for bot ${this.botName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio has been received from lex and written to a temporary disk file.
|
||||
* Start playing the audio, after killing any filler sound that might be playing.
|
||||
* When the audio completes, start the no-input timer.
|
||||
* @param {*} ep - media server endpoint
|
||||
* @param {*} evt - event data
|
||||
*/
|
||||
async _onAudioProvided(ep, cs, evt) {
|
||||
if (this.vendor) return;
|
||||
|
||||
this.waitingForPlayStart = false;
|
||||
this.logger.debug({evt}, `got audio file for bot ${this.botName}`);
|
||||
|
||||
try {
|
||||
if (this.events.includes('start-play')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'start-play', data: {path: evt.path}});
|
||||
}
|
||||
await ep.play(evt.path);
|
||||
if (this.events.includes('stop-play')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: evt.path}});
|
||||
}
|
||||
this.logger.debug({evt}, `done playing audio file for bot ${this.botName}`);
|
||||
this.ep.api('aws_lex_play_done', this.ep.uuid)
|
||||
.catch((err) => {
|
||||
this.logger.error({err}, `Error sending play_done ${this.botName}`);
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error({err}, `Error playing file ${evt.path} for both ${this.botName}`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* receive a dmtf entry from the caller.
|
||||
* If we have active dtmf instructions, collect and process accordingly.
|
||||
*/
|
||||
_onDtmf(ep, cs, evt) {
|
||||
this.logger.debug({evt}, 'Lex:_onDtmf');
|
||||
if (this.events.includes('dtmf')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'dtmf', data: evt});
|
||||
}
|
||||
if (this.passDtmf) {
|
||||
this.ep.api('aws_lex_dtmf', `${this.ep.uuid} ${evt.dtmf}`)
|
||||
.catch((err) => {
|
||||
this.logger.error({err}, `Error sending dtmf ${evt.dtmf} ${this.botName}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async _performHook(cs, hook, results) {
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
const json = await this.cs.requestor.request('verb:hook', hook, results, httpHeaders);
|
||||
if (json && Array.isArray(json)) {
|
||||
const makeTask = require('./make_task');
|
||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
if (tasks && tasks.length > 0) {
|
||||
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
|
||||
this.performAction({lexResult: 'redirect'}, false);
|
||||
cs.replaceApplication(tasks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Lex;
|
||||
@@ -2,15 +2,22 @@ const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions, ListenEvents, ListenStatus} = require('../utils/constants');
|
||||
const makeTask = require('./make_task');
|
||||
const moment = require('moment');
|
||||
const MAX_PLAY_AUDIO_QUEUE_SIZE = 10;
|
||||
const DTMF_SPAN_NAME = 'dtmf';
|
||||
|
||||
class TaskListen extends Task {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts);
|
||||
/**
|
||||
* @deprecated
|
||||
* use bidirectionalAudio.enabled
|
||||
*/
|
||||
this.disableBidirectionalAudio = opts.disableBidirectionalAudio;
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
[
|
||||
'action', 'auth', 'method', 'url', 'finishOnKey', 'maxLength', 'metadata', 'mixType', 'passDtmf', 'playBeep',
|
||||
'sampleRate', 'timeout', 'transcribe', 'wsAuth'
|
||||
'sampleRate', 'timeout', 'transcribe', 'wsAuth', 'disableBidirectionalAudio', 'channel'
|
||||
].forEach((k) => this[k] = this.data[k]);
|
||||
|
||||
this.mixType = this.mixType || 'mono';
|
||||
@@ -20,17 +27,31 @@ class TaskListen extends Task {
|
||||
this.nested = parentTask instanceof Task;
|
||||
|
||||
this.results = {};
|
||||
this.playAudioQueue = [];
|
||||
this.isPlayingAudioFromQueue = false;
|
||||
this.bidirectionalAudio = {
|
||||
enabled: this.disableBidirectionalAudio === true ? false : true,
|
||||
...(this.data['bidirectionalAudio']),
|
||||
};
|
||||
|
||||
// From drachtio-version 3.0.40, forkAudioStart will send empty bugname, metadata together with
|
||||
// bidirectionalAudio params that cause old version of freeswitch missunderstand between bugname and
|
||||
// bidirectionalAudio params
|
||||
this._bugname = 'audio_fork';
|
||||
|
||||
if (this.transcribe) this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe}, this);
|
||||
|
||||
this._dtmfHandler = this._onDtmf.bind(this);
|
||||
}
|
||||
|
||||
get name() { return TaskName.Listen; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
super.exec(cs);
|
||||
set bugname(name) { this._bugname = name; }
|
||||
|
||||
set ignoreCustomerData(val) { this._ignoreCustomerData = val; }
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
this.ep = ep;
|
||||
this._dtmfHandler = this._onDtmf.bind(this, ep);
|
||||
|
||||
try {
|
||||
this.hook = this.normalizeUrl(this.url, 'GET', this.wsAuth);
|
||||
@@ -38,7 +59,12 @@ class TaskListen extends Task {
|
||||
if (this.playBeep) await this._playBeep(ep);
|
||||
if (this.transcribeTask) {
|
||||
this.logger.debug('TaskListen:exec - starting nested transcribe task');
|
||||
this.transcribeTask.exec(cs, ep);
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.transcribeTask.summary}`);
|
||||
this.transcribeTask.span = span;
|
||||
this.transcribeTask.ctx = ctx;
|
||||
this.transcribeTask.exec(cs, {ep})
|
||||
.then((result) => span.end())
|
||||
.catch((err) => span.end());
|
||||
}
|
||||
await this._startListening(cs, ep);
|
||||
await this.awaitTaskDone();
|
||||
@@ -50,32 +76,45 @@ class TaskListen extends Task {
|
||||
this._removeListeners(ep);
|
||||
}
|
||||
|
||||
async kill() {
|
||||
super.kill();
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
this.logger.debug(`TaskListen:kill endpoint connected? ${this.ep && this.ep.connected}`);
|
||||
this._clearTimer();
|
||||
this.playAudioQueue = [];
|
||||
if (this.ep && this.ep.connected) {
|
||||
this.logger.debug('TaskListen:kill closing websocket');
|
||||
await this.ep.forkAudioStop()
|
||||
.catch((err) => this.logger.info(err, 'TaskListen:kill'));
|
||||
try {
|
||||
const args = this._bugname ? [this._bugname] : [];
|
||||
await this.ep.forkAudioStop(...args);
|
||||
this.logger.debug('TaskListen:kill successfully closed websocket');
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'TaskListen:kill');
|
||||
}
|
||||
}
|
||||
if (this.recordStartTime) {
|
||||
const duration = moment().diff(this.recordStartTime, 'seconds');
|
||||
this.results.dialCallDuration = duration;
|
||||
}
|
||||
if (this.transcribeTask) await this.transcribeTask.kill();
|
||||
if (this.transcribeTask) {
|
||||
await this.transcribeTask.kill(cs);
|
||||
this.transcribeTask = null;
|
||||
}
|
||||
this.ep && this._removeListeners(this.ep);
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
async updateListen(status) {
|
||||
if (!this.killed && this.ep && this.ep.connected) {
|
||||
const args = this._bugname ? [this._bugname] : [];
|
||||
this.logger.info(`TaskListen:updateListen status ${status}`);
|
||||
switch (status) {
|
||||
case ListenStatus.Pause:
|
||||
await this.ep.forkAudioPause().catch((err) => this.logger.info(err, 'TaskListen: error pausing audio'));
|
||||
await this.ep.forkAudioPause(...args)
|
||||
.catch((err) => this.logger.info(err, 'TaskListen: error pausing audio'));
|
||||
break;
|
||||
case ListenStatus.Resume:
|
||||
await this.ep.forkAudioResume().catch((err) => this.logger.info(err, 'TaskListen: error resuming audio'));
|
||||
await this.ep.forkAudioResume(...args)
|
||||
.catch((err) => this.logger.info(err, 'TaskListen: error resuming audio'));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -88,13 +127,15 @@ class TaskListen extends Task {
|
||||
|
||||
async _startListening(cs, ep) {
|
||||
this._initListeners(ep);
|
||||
const ci = this.nested ? this.parentTask.sd.callInfo : cs.callInfo.toJSON();
|
||||
if (this._ignoreCustomerData) {
|
||||
delete ci.customerData;
|
||||
}
|
||||
const metadata = Object.assign(
|
||||
{sampleRate: this.sampleRate, mixType: this.mixType},
|
||||
this.nested ? this.parentTask.sd.callInfo : cs.callInfo.toJSON(),
|
||||
ci,
|
||||
this.metadata);
|
||||
if (this.hook.auth) {
|
||||
this.logger.debug({username: this.hook.auth.username, password: this.hook.auth.password},
|
||||
'TaskListen:_startListening basic auth');
|
||||
await this.ep.set({
|
||||
'MOD_AUDIO_BASIC_AUTH_USERNAME': this.hook.auth.username,
|
||||
'MOD_AUDIO_BASIC_AUTH_PASSWORD': this.hook.auth.password
|
||||
@@ -104,13 +145,15 @@ class TaskListen extends Task {
|
||||
wsUrl: this.hook.url,
|
||||
mixType: this.mixType,
|
||||
sampling: this.sampleRate,
|
||||
metadata
|
||||
...(this._bugname && {bugname: this._bugname}),
|
||||
metadata,
|
||||
bidirectionalAudio: this.bidirectionalAudio || {}
|
||||
});
|
||||
this.recordStartTime = moment();
|
||||
if (this.maxLength) {
|
||||
this._timer = setTimeout(() => {
|
||||
this.logger.debug(`TaskListen terminating task due to timeout of ${this.timeout}s reached`);
|
||||
this.kill();
|
||||
this.kill(cs);
|
||||
}, this.maxLength * 1000);
|
||||
}
|
||||
}
|
||||
@@ -122,6 +165,13 @@ class TaskListen extends Task {
|
||||
if (this.finishOnKey || this.passDtmf) {
|
||||
ep.on('dtmf', this._dtmfHandler);
|
||||
}
|
||||
|
||||
/* support bi-directional audio */
|
||||
if (this.bidirectionalAudio.enabled) {
|
||||
ep.addCustomEventListener(ListenEvents.PlayAudio, this._onPlayAudio.bind(this, ep));
|
||||
}
|
||||
ep.addCustomEventListener(ListenEvents.KillAudio, this._onKillAudio.bind(this, ep));
|
||||
ep.addCustomEventListener(ListenEvents.Disconnect, this._onDisconnect.bind(this, ep));
|
||||
}
|
||||
|
||||
_removeListeners(ep) {
|
||||
@@ -131,9 +181,32 @@ class TaskListen extends Task {
|
||||
if (this.finishOnKey || this.passDtmf) {
|
||||
ep.removeListener('dtmf', this._dtmfHandler);
|
||||
}
|
||||
ep.removeCustomEventListener(ListenEvents.PlayAudio);
|
||||
ep.removeCustomEventListener(ListenEvents.KillAudio);
|
||||
ep.removeCustomEventListener(ListenEvents.Disconnect);
|
||||
|
||||
}
|
||||
|
||||
_onDtmf(evt) {
|
||||
_onDtmf(ep, evt) {
|
||||
const {dtmf, duration} = evt;
|
||||
this.logger.debug({evt}, `TaskListen:_onDtmf received dtmf ${dtmf}`);
|
||||
if (this.passDtmf && this.ep?.connected) {
|
||||
const obj = {event: 'dtmf', dtmf, duration};
|
||||
const args = this._bugname ? [this._bugname, obj] : [obj];
|
||||
this.ep.forkAudioSendText(...args)
|
||||
.catch((err) => this.logger.info({err}, 'TaskListen:_onDtmf error sending dtmf'));
|
||||
}
|
||||
|
||||
/* add a child span for the dtmf event */
|
||||
const msDuration = Math.floor((duration / 8000) * 1000);
|
||||
const {span} = this.startChildSpan(`${DTMF_SPAN_NAME}:${dtmf}`);
|
||||
span.setAttributes({
|
||||
channel: 1,
|
||||
dtmf,
|
||||
duration: `${msDuration}ms`
|
||||
});
|
||||
span.end();
|
||||
|
||||
if (evt.dtmf === this.finishOnKey) {
|
||||
this.logger.info(`TaskListen:_onDtmf terminating task due to dtmf ${evt.dtmf}`);
|
||||
this.results.digits = evt.dtmf;
|
||||
@@ -148,17 +221,86 @@ 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');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
async _playAudio(ep, evt, logger) {
|
||||
try {
|
||||
const results = await ep.play(evt.file);
|
||||
logger.debug(`Finished playing file, result: ${JSON.stringify(results)}`);
|
||||
const obj = {
|
||||
type: 'playDone',
|
||||
data: {
|
||||
id: evt.id,
|
||||
...results
|
||||
}
|
||||
};
|
||||
const args = this._bugname ? [this._bugname, obj] : [obj];
|
||||
ep.forkAudioSendText(...args);
|
||||
} catch (err) {
|
||||
logger.error({err}, 'Error playing file');
|
||||
}
|
||||
}
|
||||
|
||||
async _onPlayAudio(ep, evt) {
|
||||
this.logger.info(`received play_audio event: ${JSON.stringify(evt)}`);
|
||||
if (!evt.queuePlay) {
|
||||
this.playAudioQueue = [];
|
||||
this._playAudio(ep, evt, this.logger);
|
||||
this.isPlayingAudioFromQueue = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.playAudioQueue.length <= MAX_PLAY_AUDIO_QUEUE_SIZE) {
|
||||
this.playAudioQueue.push(evt);
|
||||
}
|
||||
|
||||
if (this.isPlayingAudioFromQueue) return;
|
||||
|
||||
this.isPlayingAudioFromQueue = true;
|
||||
while (this.playAudioQueue.length > 0) {
|
||||
await this._playAudio(ep, this.playAudioQueue.shift(), this.logger);
|
||||
}
|
||||
this.isPlayingAudioFromQueue = false;
|
||||
}
|
||||
|
||||
_onKillAudio(ep) {
|
||||
this.logger.info('received kill_audio event');
|
||||
ep.api('uuid_break', ep.uuid);
|
||||
}
|
||||
|
||||
_onDisconnect(ep, cs) {
|
||||
this.logger.debug('_onDisconnect: TaskListen terminating task');
|
||||
this.kill(cs);
|
||||
}
|
||||
|
||||
_onError(ep, evt) {
|
||||
this.logger.info(evt, 'TaskListen:_onError');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
/**
|
||||
* play or say something during the call
|
||||
* @param {*} tasks - array of play/say tasks to execute
|
||||
*/
|
||||
async whisper(tasks, callSid) {
|
||||
try {
|
||||
const cs = this.callSession;
|
||||
this.logger.debug('Listen:whisper tasks starting');
|
||||
while (tasks.length && !cs.callGone) {
|
||||
const task = tasks.shift();
|
||||
await task.exec(cs, {ep: this.ep});
|
||||
}
|
||||
this.logger.debug('Listen:whisper tasks complete');
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'Listen:whisper error');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = TaskListen;
|
||||
|
||||
144
lib/tasks/llm/index.js
Normal file
144
lib/tasks/llm/index.js
Normal file
@@ -0,0 +1,144 @@
|
||||
const Task = require('../task');
|
||||
const {TaskPreconditions} = require('../../utils/constants');
|
||||
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) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
['vendor', 'model', 'auth', 'connectOptions'].forEach((prop) => {
|
||||
this[prop] = this.data[prop];
|
||||
});
|
||||
|
||||
this.eventHandlers = [];
|
||||
|
||||
// delegate to the specific llm model
|
||||
this.llm = this.createSpecificLlm();
|
||||
// MCP
|
||||
this.mcpServers = this.data.mcpServers || [];
|
||||
}
|
||||
|
||||
get name() { return this.llm.name ; }
|
||||
|
||||
get toolHook() { return this.llm?.toolHook; }
|
||||
|
||||
get eventHook() { return this.llm?.eventHook; }
|
||||
|
||||
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() {
|
||||
let llm;
|
||||
switch (this.vendor) {
|
||||
case 'openai':
|
||||
case 'microsoft':
|
||||
llm = new TaskLlmOpenAI_S2S(this.logger, this.data, this);
|
||||
break;
|
||||
|
||||
case 'voiceagent':
|
||||
case 'deepgram':
|
||||
llm = new TaskLlmVoiceAgent_S2S(this.logger, this.data, this);
|
||||
break;
|
||||
|
||||
case 'ultravox':
|
||||
llm = new TaskLlmUltravox_S2S(this.logger, this.data, this);
|
||||
break;
|
||||
|
||||
case 'elevenlabs':
|
||||
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`);
|
||||
}
|
||||
|
||||
if (!llm) {
|
||||
throw new Error(`Unsupported vendor:model ${this.vendor}:${this.model}`);
|
||||
}
|
||||
return llm;
|
||||
}
|
||||
|
||||
addCustomEventListener(ep, event, handler) {
|
||||
this.eventHandlers.push({ep, event, handler});
|
||||
ep.addCustomEventListener(event, handler);
|
||||
}
|
||||
|
||||
removeCustomEventListeners() {
|
||||
this.eventHandlers.forEach((h) => h.ep.removeCustomEventListener(h.event, h.handler));
|
||||
}
|
||||
|
||||
async sendEventHook(data) {
|
||||
await this.cs?.requestor.request('llm:event', this.eventHook, data);
|
||||
}
|
||||
|
||||
|
||||
async sendToolHook(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) {
|
||||
if (!this.ep.connected) {
|
||||
this.logger.info('TaskLlm:processToolOutput - no connected endpoint');
|
||||
return;
|
||||
}
|
||||
this.llm.processToolOutput(this.ep, tool_call_id, data);
|
||||
}
|
||||
|
||||
async processLlmUpdate(data, callSid) {
|
||||
if (this.ep.connected) {
|
||||
if (typeof this.llm.processLlmUpdate === 'function') {
|
||||
this.llm.processLlmUpdate(this.ep, data, callSid);
|
||||
}
|
||||
else {
|
||||
const {vendor, model} = this.llm;
|
||||
this.logger.info({data, callSid},
|
||||
`TaskLlm:_processLlmUpdate: LLM ${vendor}:${model} does not support llm:update`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskLlm;
|
||||
327
lib/tasks/llm/llms/elevenlabs_s2s.js
Normal file
327
lib/tasks/llm/llms/elevenlabs_s2s.js
Normal file
@@ -0,0 +1,327 @@
|
||||
const Task = require('../../task');
|
||||
const TaskName = 'Llm_Elevenlabs_s2s';
|
||||
const {LlmEvents_Elevenlabs} = require('../../../utils/constants');
|
||||
const {request} = require('undici');
|
||||
const ClientEvent = 'client.event';
|
||||
const SessionDelete = 'session.delete';
|
||||
|
||||
const elevenlabs_server_events = [
|
||||
'conversation_initiation_metadata',
|
||||
'user_transcript',
|
||||
'agent_response',
|
||||
'client_tool_call'
|
||||
];
|
||||
|
||||
const expandWildcards = (events) => {
|
||||
const expandedEvents = [];
|
||||
|
||||
events.forEach((evt) => {
|
||||
if (evt.endsWith('.*')) {
|
||||
const prefix = evt.slice(0, -2); // Remove the wildcard ".*"
|
||||
const matchingEvents = elevenlabs_server_events.filter((e) => e.startsWith(prefix));
|
||||
expandedEvents.push(...matchingEvents);
|
||||
} else {
|
||||
expandedEvents.push(evt);
|
||||
}
|
||||
});
|
||||
|
||||
return expandedEvents;
|
||||
};
|
||||
|
||||
class TaskLlmElevenlabs_S2S extends Task {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts, parentTask);
|
||||
this.parent = parentTask;
|
||||
|
||||
this.vendor = this.parent.vendor;
|
||||
this.auth = this.parent.auth;
|
||||
|
||||
const {agent_id, api_key} = this.auth || {};
|
||||
if (!agent_id) throw new Error('auth.agent_id is required for Elevenlabs S2S');
|
||||
|
||||
this.agent_id = agent_id;
|
||||
this.api_key = api_key;
|
||||
this.actionHook = this.data.actionHook;
|
||||
this.eventHook = this.data.eventHook;
|
||||
this.toolHook = this.data.toolHook;
|
||||
const {
|
||||
conversation_initiation_client_data,
|
||||
input_sample_rate = 16000,
|
||||
output_sample_rate = 16000
|
||||
} = this.data.llmOptions;
|
||||
this.conversation_initiation_client_data = conversation_initiation_client_data;
|
||||
this.input_sample_rate = input_sample_rate;
|
||||
this.output_sample_rate = output_sample_rate;
|
||||
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 || elevenlabs_server_events);
|
||||
|
||||
this.addCustomEventListener = parentTask.addCustomEventListener.bind(parentTask);
|
||||
this.removeCustomEventListeners = parentTask.removeCustomEventListeners.bind(parentTask);
|
||||
}
|
||||
|
||||
get name() { return TaskName; }
|
||||
|
||||
async getSignedUrl() {
|
||||
if (!this.api_key) {
|
||||
return {
|
||||
host: 'api.elevenlabs.io',
|
||||
path: `/v1/convai/conversation?agent_id=${this.agent_id}`,
|
||||
};
|
||||
}
|
||||
|
||||
const {statusCode, body} = await request(
|
||||
`https://api.elevenlabs.io/v1/convai/conversation/get_signed_url?agent_id=${this.agent_id}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'xi-api-key': this.api_key
|
||||
},
|
||||
}
|
||||
);
|
||||
const data = await body.json();
|
||||
if (statusCode !== 200 || !data?.signed_url) {
|
||||
this.logger.error({statusCode, data}, 'Elevenlabs Error registering call');
|
||||
throw new Error(`Elevenlabs Error registering call: ${data.message}`);
|
||||
}
|
||||
|
||||
const url = new URL(data.signed_url);
|
||||
return {
|
||||
host: url.hostname,
|
||||
path: url.pathname + url.search,
|
||||
};
|
||||
}
|
||||
|
||||
async _api(ep, args) {
|
||||
const res = await ep.api('uuid_elevenlabs_s2s', `^^|${args.join('|')}`);
|
||||
if (!res.body?.startsWith('+OK')) {
|
||||
throw new Error({args}, `Error calling uuid_elevenlabs_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}, 'TaskLlmElevenlabs_S2S:kill - error deleting session'));
|
||||
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send function call output to the Elevenlabs server in the form of conversation.item.create
|
||||
* per https://elevenlabs.io/docs/conversational-ai/api-reference/conversational-ai/websocket
|
||||
*/
|
||||
async processToolOutput(ep, tool_call_id, rawData) {
|
||||
try {
|
||||
const {data} = rawData;
|
||||
this.logger.debug({tool_call_id, data}, 'TaskLlmElevenlabs_S2S:processToolOutput');
|
||||
|
||||
if (!data.type || data.type !== 'client_tool_result') {
|
||||
this.logger.info({data},
|
||||
'TaskLlmElevenlabs_S2S:processToolOutput - invalid tool output, must be client_tool_result');
|
||||
}
|
||||
else {
|
||||
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(data)]);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'TaskLlmElevenlabs_S2S:processToolOutput');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a session.update to the Elevenlabs server
|
||||
* Note: creating and deleting conversation items also supported as well as interrupting the assistant
|
||||
*/
|
||||
async processLlmUpdate(ep, data, _callSid) {
|
||||
this.logger.debug({data, _callSid}, 'TaskLlmElevenlabs_S2S:processLlmUpdate, ignored');
|
||||
}
|
||||
|
||||
async _startListening(cs, ep) {
|
||||
this._registerHandlers(ep);
|
||||
|
||||
try {
|
||||
const {host, path} = await this.getSignedUrl();
|
||||
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');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
async _sendClientEvent(ep, obj) {
|
||||
let ok = true;
|
||||
this.logger.debug({obj}, 'TaskLlmElevenlabs_S2S:_sendClientEvent');
|
||||
try {
|
||||
const args = [ep.uuid, ClientEvent, JSON.stringify(obj)];
|
||||
await this._api(ep, args);
|
||||
} catch (err) {
|
||||
ok = false;
|
||||
this.logger.error({err}, 'TaskLlmElevenlabs_S2S:_sendClientEvent - Error');
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
async _sendInitialMessage(ep) {
|
||||
if (this.conversation_initiation_client_data) {
|
||||
if (!await this._sendClientEvent(ep, {
|
||||
type: 'conversation_initiation_client_data',
|
||||
...this.conversation_initiation_client_data
|
||||
})) {
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_registerHandlers(ep) {
|
||||
this.addCustomEventListener(ep, LlmEvents_Elevenlabs.Connect, this._onConnect.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_Elevenlabs.ConnectFailure, this._onConnectFailure.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_Elevenlabs.Disconnect, this._onDisconnect.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_Elevenlabs.ServerEvent, this._onServerEvent.bind(this, ep));
|
||||
}
|
||||
|
||||
_unregisterHandlers() {
|
||||
this.removeCustomEventListeners();
|
||||
}
|
||||
|
||||
_onError(ep, evt) {
|
||||
this.logger.info({evt}, 'TaskLlmElevenlabs_S2S:_onError');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
_onConnect(ep) {
|
||||
this.logger.debug('TaskLlmElevenlabs_S2S:_onConnect');
|
||||
this._sendInitialMessage(ep);
|
||||
}
|
||||
_onConnectFailure(_ep, evt) {
|
||||
this.logger.info(evt, 'TaskLlmElevenlabs_S2S:_onConnectFailure');
|
||||
this.results = {completionReason: 'connection failure'};
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
_onDisconnect(_ep, evt) {
|
||||
this.logger.info(evt, 'TaskLlmElevenlabs_S2S:_onConnectFailure');
|
||||
this.results = {completionReason: 'disconnect from remote end'};
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
async _onServerEvent(ep, evt) {
|
||||
let endConversation = false;
|
||||
const type = evt.type;
|
||||
this.logger.info({evt}, 'TaskLlmElevenlabs_S2S:_onServerEvent');
|
||||
|
||||
if (type === 'error') {
|
||||
endConversation = true;
|
||||
this.results = {
|
||||
completionReason: 'server error',
|
||||
error: evt.error
|
||||
};
|
||||
}
|
||||
|
||||
/* tool calls */
|
||||
else if (type === 'client_tool_call') {
|
||||
this.logger.debug({evt}, 'TaskLlmElevenlabs_S2S:_onServerEvent - function_call');
|
||||
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 {
|
||||
try {
|
||||
await this.parent.sendToolHook(call_id, {name, args});
|
||||
} catch (err) {
|
||||
this.logger.info({err, evt}, 'TaskLlmElevenlabs_S2S - error calling function');
|
||||
this.results = {
|
||||
completionReason: 'client error calling function',
|
||||
error: err
|
||||
};
|
||||
endConversation = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 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},
|
||||
'TaskLlmElevenlabs_S2S:_onServerEvent - error sending event hook'));
|
||||
}
|
||||
|
||||
if (endConversation) {
|
||||
this.logger.info({results: this.results},
|
||||
'TaskLlmElevenlabs_S2S:_onServerEvent - ending conversation due to error');
|
||||
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 = elevenlabs_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
|
||||
}, 'TaskLlmElevenlabs_S2S:_populateEvents');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskLlmElevenlabs_S2S;
|
||||
319
lib/tasks/llm/llms/google_s2s.js
Normal file
319
lib/tasks/llm/llms/google_s2s.js
Normal 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;
|
||||
398
lib/tasks/llm/llms/openai_s2s.js
Normal file
398
lib/tasks/llm/llms/openai_s2s.js
Normal file
@@ -0,0 +1,398 @@
|
||||
const Task = require('../../task');
|
||||
const TaskName = 'Llm_OpenAI_s2s';
|
||||
const {LlmEvents_OpenAI} = require('../../../utils/constants');
|
||||
const ClientEvent = 'client.event';
|
||||
const SessionDelete = 'session.delete';
|
||||
|
||||
const openai_server_events = [
|
||||
'error',
|
||||
'session.created',
|
||||
'session.updated',
|
||||
'conversation.created',
|
||||
'input_audio_buffer.committed',
|
||||
'input_audio_buffer.cleared',
|
||||
'input_audio_buffer.speech_started',
|
||||
'input_audio_buffer.speech_stopped',
|
||||
'conversation.item.created',
|
||||
'conversation.item.input_audio_transcription.completed',
|
||||
'conversation.item.input_audio_transcription.failed',
|
||||
'conversation.item.truncated',
|
||||
'conversation.item.deleted',
|
||||
'response.created',
|
||||
'response.done',
|
||||
'response.output_item.added',
|
||||
'response.output_item.done',
|
||||
'response.content_part.added',
|
||||
'response.content_part.done',
|
||||
'response.text.delta',
|
||||
'response.text.done',
|
||||
'response.audio_transcript.delta',
|
||||
'response.audio_transcript.done',
|
||||
'response.audio.delta',
|
||||
'response.audio.done',
|
||||
'response.function_call_arguments.delta',
|
||||
'response.function_call_arguments.done',
|
||||
'rate_limits.updated',
|
||||
'output_audio.playback_started',
|
||||
'output_audio.playback_stopped',
|
||||
];
|
||||
|
||||
const expandWildcards = (events) => {
|
||||
const expandedEvents = [];
|
||||
|
||||
events.forEach((evt) => {
|
||||
if (evt.endsWith('.*')) {
|
||||
const prefix = evt.slice(0, -2); // Remove the wildcard ".*"
|
||||
const matchingEvents = openai_server_events.filter((e) => e.startsWith(prefix));
|
||||
expandedEvents.push(...matchingEvents);
|
||||
} else {
|
||||
expandedEvents.push(evt);
|
||||
}
|
||||
});
|
||||
|
||||
return expandedEvents;
|
||||
};
|
||||
|
||||
class TaskLlmOpenAI_S2S extends Task {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts, parentTask);
|
||||
this.parent = parentTask;
|
||||
|
||||
this.vendor = this.parent.vendor;
|
||||
this.model = this.parent.model || 'gpt-4o-realtime-preview-2024-12-17';
|
||||
this.auth = this.parent.auth;
|
||||
this.connectionOptions = this.parent.connectOptions;
|
||||
|
||||
const {apiKey} = this.auth || {};
|
||||
if (!apiKey) throw new Error('auth.apiKey is required for OpenAI S2S');
|
||||
|
||||
if (['openai', 'microsoft'].indexOf(this.vendor) === -1) {
|
||||
throw new Error(`Invalid vendor ${this.vendor} for OpenAI S2S`);
|
||||
}
|
||||
|
||||
if ('microsoft' === this.vendor && !this.connectionOptions?.host) {
|
||||
throw new Error('connectionOptions.host is required for Microsoft OpenAI S2S');
|
||||
}
|
||||
|
||||
this.apiKey = apiKey;
|
||||
this.authType = 'microsoft' === this.vendor ? 'query' : 'bearer';
|
||||
this.actionHook = this.data.actionHook;
|
||||
this.eventHook = this.data.eventHook;
|
||||
this.toolHook = this.data.toolHook;
|
||||
const {response_create, session_update} = this.data.llmOptions;
|
||||
|
||||
if (typeof response_create !== 'object') {
|
||||
throw new Error('llmOptions with an initial response.create is required for OpenAI S2S');
|
||||
}
|
||||
|
||||
this.response_create = response_create;
|
||||
this.session_update = session_update;
|
||||
|
||||
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 || openai_server_events);
|
||||
|
||||
this.addCustomEventListener = parentTask.addCustomEventListener.bind(parentTask);
|
||||
this.removeCustomEventListeners = parentTask.removeCustomEventListeners.bind(parentTask);
|
||||
}
|
||||
|
||||
get name() { return TaskName; }
|
||||
|
||||
get host() {
|
||||
const {host} = this.connectionOptions || {};
|
||||
return host || (this.vendor === 'openai' ? 'api.openai.com' : void 0);
|
||||
}
|
||||
|
||||
get path() {
|
||||
const {path} = this.connectionOptions || {};
|
||||
if (path) return path;
|
||||
|
||||
switch (this.vendor) {
|
||||
case 'openai':
|
||||
return `v1/realtime?model=${this.model}`;
|
||||
case 'microsoft':
|
||||
return `openai/realtime?api-version=2024-10-01-preview&deployment=${this.model}`;
|
||||
}
|
||||
}
|
||||
|
||||
async _api(ep, args) {
|
||||
const res = await ep.api('uuid_openai_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}, 'TaskLlmOpenAI_S2S:kill - error deleting session'));
|
||||
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send function call output to the OpenAI server in the form of conversation.item.create
|
||||
* per https://platform.openai.com/docs/guides/realtime/function-calls
|
||||
*/
|
||||
async processToolOutput(ep, tool_call_id, data) {
|
||||
try {
|
||||
this.logger.debug({tool_call_id, data}, 'TaskLlmOpenAI_S2S:processToolOutput');
|
||||
|
||||
if (!data.type || data.type !== 'conversation.item.create') {
|
||||
this.logger.info({data},
|
||||
'TaskLlmOpenAI_S2S:processToolOutput - invalid tool output, must be conversation.item.create');
|
||||
}
|
||||
else {
|
||||
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(data)]);
|
||||
|
||||
// spec also recommends to send immediate response.create
|
||||
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify({type: 'response.create'})]);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'TaskLlmOpenAI_S2S:processToolOutput');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a session.update to the OpenAI server
|
||||
* Note: creating and deleting conversation items also supported as well as interrupting the assistant
|
||||
*/
|
||||
async processLlmUpdate(ep, data, _callSid) {
|
||||
try {
|
||||
this.logger.debug({data, _callSid}, 'TaskLlmOpenAI_S2S:processLlmUpdate');
|
||||
|
||||
if (!data.type || ![
|
||||
'session.update',
|
||||
'conversation.item.create',
|
||||
'conversation.item.delete',
|
||||
'response.cancel'
|
||||
].includes(data.type)) {
|
||||
this.logger.info({data}, 'TaskLlmOpenAI_S2S:processLlmUpdate - invalid mid-call request');
|
||||
}
|
||||
else {
|
||||
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(data)]);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'TaskLlmOpenAI_S2S:processLlmUpdate');
|
||||
}
|
||||
}
|
||||
|
||||
async _startListening(cs, ep) {
|
||||
this._registerHandlers(ep);
|
||||
|
||||
try {
|
||||
const args = [ep.uuid, 'session.create', this.host, this.path, this.authType, this.apiKey];
|
||||
await this._api(ep, args);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'TaskLlmOpenAI_S2S:_startListening');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
async _sendClientEvent(ep, obj) {
|
||||
let ok = true;
|
||||
this.logger.debug({obj}, 'TaskLlmOpenAI_S2S:_sendClientEvent');
|
||||
try {
|
||||
const args = [ep.uuid, ClientEvent, JSON.stringify(obj)];
|
||||
await this._api(ep, args);
|
||||
} catch (err) {
|
||||
ok = false;
|
||||
this.logger.error({err}, 'TaskLlmOpenAI_S2S:_sendClientEvent - Error');
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
async _sendInitialMessage(ep) {
|
||||
let obj = {type: 'response.create', response: this.response_create};
|
||||
if (!await this._sendClientEvent(ep, obj)) {
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
/* 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)) {
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_registerHandlers(ep) {
|
||||
this.addCustomEventListener(ep, LlmEvents_OpenAI.Connect, this._onConnect.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_OpenAI.ConnectFailure, this._onConnectFailure.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_OpenAI.Disconnect, this._onDisconnect.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_OpenAI.ServerEvent, this._onServerEvent.bind(this, ep));
|
||||
}
|
||||
|
||||
_unregisterHandlers() {
|
||||
this.removeCustomEventListeners();
|
||||
}
|
||||
|
||||
_onError(ep, evt) {
|
||||
this.logger.info({evt}, 'TaskLlmOpenAI_S2S:_onError');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
_onConnect(ep) {
|
||||
this.logger.debug('TaskLlmOpenAI_S2S:_onConnect');
|
||||
this._sendInitialMessage(ep);
|
||||
}
|
||||
_onConnectFailure(_ep, evt) {
|
||||
this.logger.info(evt, 'TaskLlmOpenAI_S2S:_onConnectFailure');
|
||||
this.results = {completionReason: 'connection failure'};
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
_onDisconnect(_ep, evt) {
|
||||
this.logger.info(evt, 'TaskLlmOpenAI_S2S:_onConnectFailure');
|
||||
this.results = {completionReason: 'disconnect from remote end'};
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
async _onServerEvent(ep, evt) {
|
||||
let endConversation = false;
|
||||
const type = evt.type;
|
||||
this.logger.info({evt}, 'TaskLlmOpenAI_S2S:_onServerEvent');
|
||||
|
||||
/* check for failures, such as rate limit exceeded, that should terminate the conversation */
|
||||
if (type === 'response.done' && evt.response.status === 'failed') {
|
||||
endConversation = true;
|
||||
this.results = {
|
||||
completionReason: 'server failure',
|
||||
error: evt.response.status_details?.error
|
||||
};
|
||||
}
|
||||
|
||||
/* server errors of some sort */
|
||||
else if (type === 'error') {
|
||||
endConversation = true;
|
||||
this.results = {
|
||||
completionReason: 'server error',
|
||||
error: evt.error
|
||||
};
|
||||
}
|
||||
|
||||
/* tool calls */
|
||||
else if (type === 'response.output_item.done' && evt.item?.type === 'function_call') {
|
||||
this.logger.debug({evt}, 'TaskLlmOpenAI_S2S:_onServerEvent - function_call');
|
||||
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 {
|
||||
try {
|
||||
await this.parent.sendToolHook(call_id, {name, args});
|
||||
} catch (err) {
|
||||
this.logger.info({err, evt}, 'TaskLlmOpenAI - error calling function');
|
||||
this.results = {
|
||||
completionReason: 'client error calling function',
|
||||
error: err
|
||||
};
|
||||
endConversation = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 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}, 'TaskLlmOpenAI_S2S:_onServerEvent - error sending event hook'));
|
||||
}
|
||||
|
||||
if (endConversation) {
|
||||
this.logger.info({results: this.results}, 'TaskLlmOpenAI_S2S:_onServerEvent - ending conversation due to error');
|
||||
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 = openai_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
|
||||
}, 'TaskLlmOpenAI_S2S:_populateEvents');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskLlmOpenAI_S2S;
|
||||
344
lib/tasks/llm/llms/ultravox_s2s.js
Normal file
344
lib/tasks/llm/llms/ultravox_s2s.js
Normal file
@@ -0,0 +1,344 @@
|
||||
const Task = require('../../task');
|
||||
const TaskName = 'Llm_Ultravox_s2s';
|
||||
const {request} = require('undici');
|
||||
const {LlmEvents_Ultravox} = require('../../../utils/constants');
|
||||
|
||||
const ultravox_server_events = [
|
||||
'createCall',
|
||||
'pong',
|
||||
'state',
|
||||
'transcript',
|
||||
'conversationText',
|
||||
'clientToolInvocation',
|
||||
'playbackClearBuffer',
|
||||
];
|
||||
|
||||
const ClientEvent = 'client.event';
|
||||
|
||||
const expandWildcards = (events) => {
|
||||
// no-op for deepgram
|
||||
return events;
|
||||
};
|
||||
|
||||
const SessionDelete = 'session.delete';
|
||||
|
||||
class TaskLlmUltravox_S2S extends Task {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts, parentTask);
|
||||
this.parent = parentTask;
|
||||
|
||||
this.vendor = this.parent.vendor;
|
||||
this.model = this.parent.model || 'fixie-ai/ultravox';
|
||||
this.auth = this.parent.auth;
|
||||
this.connectionOptions = this.parent.connectOptions;
|
||||
|
||||
const {apiKey} = this.auth || {};
|
||||
if (!apiKey) throw new Error('auth.apiKey is required for Vendor: Ultravox');
|
||||
this.apiKey = apiKey;
|
||||
this.actionHook = this.data.actionHook;
|
||||
this.eventHook = this.data.eventHook;
|
||||
this.toolHook = this.data.toolHook;
|
||||
|
||||
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 || ultravox_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_ultravox_s2s', `^^|${args.join('|')}`);
|
||||
if (!res.body?.startsWith('+OK')) {
|
||||
throw new Error(`Error calling uuid_ultravox_s2s: ${JSON.stringify(res.body)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.data.llmOptions.selectedTools = [
|
||||
...convertedTools,
|
||||
...(this.data.llmOptions.selectedTools || [])
|
||||
];
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...this.data.llmOptions,
|
||||
model: this.model,
|
||||
medium: {
|
||||
...(this.data.llmOptions.medium || {}),
|
||||
serverWebSocket: {
|
||||
inputSampleRate: 8000,
|
||||
outputSampleRate: 8000,
|
||||
}
|
||||
}
|
||||
};
|
||||
const {statusCode, body} = await request('https://api.ultravox.ai/api/calls', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': this.apiKey
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await body.json();
|
||||
if (statusCode !== 201 || !data?.joinUrl) {
|
||||
this.logger.info({statusCode, data}, 'Ultravox Error registering call');
|
||||
throw new Error(`Ultravox Error registering call:${statusCode} - ${data.detail}`);
|
||||
}
|
||||
this.logger.debug({joinUrl: data.joinUrl}, 'Ultravox Call registered');
|
||||
return data;
|
||||
}
|
||||
|
||||
_unregisterHandlers() {
|
||||
this.removeCustomEventListeners();
|
||||
}
|
||||
|
||||
_registerHandlers(ep) {
|
||||
this.addCustomEventListener(ep, LlmEvents_Ultravox.Connect, this._onConnect.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_Ultravox.ConnectFailure, this._onConnectFailure.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_Ultravox.Disconnect, this._onDisconnect.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_Ultravox.ServerEvent, this._onServerEvent.bind(this, ep));
|
||||
}
|
||||
|
||||
async _startListening(cs, ep) {
|
||||
this._registerHandlers(ep);
|
||||
|
||||
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
|
||||
this._sendLlmEvent('createCall', {
|
||||
type: 'createCall',
|
||||
...data
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'TaskLlmUltraVox_S2S:_startListening - Error sending createCall');
|
||||
this.results = {completionReason: `connection failure - ${err}`};
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
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}, 'TaskLlmUltravox_S2S:kill - error deleting session'));
|
||||
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
_onConnect(ep) {
|
||||
this.logger.info('TaskLlmUltravox_S2S:_onConnect');
|
||||
}
|
||||
_onConnectFailure(_ep, evt) {
|
||||
this.logger.info(evt, 'TaskLlmUltravox_S2S:_onConnectFailure');
|
||||
this.results = {completionReason: 'connection failure'};
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
_onDisconnect(_ep, evt) {
|
||||
this.logger.info(evt, 'TaskLlmUltravox_S2S:_onConnectFailure');
|
||||
this.results = {completionReason: 'disconnect from remote end'};
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
async _onServerEvent(_ep, evt) {
|
||||
let endConversation = false;
|
||||
const type = evt.type;
|
||||
this.logger.debug({evt}, 'TaskLlmUltravox_S2S:_onServerEvent');
|
||||
|
||||
/* server errors of some sort */
|
||||
if (type === 'error') {
|
||||
endConversation = true;
|
||||
this.results = {
|
||||
completionReason: 'server error',
|
||||
error: evt.error
|
||||
};
|
||||
}
|
||||
|
||||
/* tool calls */
|
||||
else if (type === 'client_tool_invocation') {
|
||||
this.logger.debug({evt}, 'TaskLlmUltravox_S2S:_onServerEvent - function_call');
|
||||
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 {
|
||||
try {
|
||||
await this.parent.sendToolHook(call_id, {name, args});
|
||||
} catch (err) {
|
||||
this.logger.info({err, evt}, 'TaskLlmUltravox_S2S - error calling function');
|
||||
this.results = {
|
||||
completionReason: 'client error calling function',
|
||||
error: err
|
||||
};
|
||||
endConversation = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._sendLlmEvent(type, evt);
|
||||
|
||||
if (endConversation) {
|
||||
this.logger.info({results: this.results},
|
||||
'TaskLlmUltravox_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}, 'TaskLlmUltravox_S2S:_onServerEvent - error sending event hook'));
|
||||
}
|
||||
}
|
||||
|
||||
async processLlmUpdate(ep, data, _callSid) {
|
||||
try {
|
||||
this.logger.debug({data, _callSid}, 'TaskLlmUltravox_S2S:processLlmUpdate');
|
||||
|
||||
if (!data.type || ![
|
||||
'input_text_message'
|
||||
].includes(data.type)) {
|
||||
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, data}, 'TaskLlmUltravox_S2S:processLlmUpdate - Error processing LLM update');
|
||||
}
|
||||
}
|
||||
|
||||
async processToolOutput(ep, tool_call_id, data) {
|
||||
try {
|
||||
this.logger.debug({tool_call_id, data}, 'TaskLlmUltravox_S2S:processToolOutput');
|
||||
|
||||
if (!data.type || data.type !== 'client_tool_result') {
|
||||
this.logger.info({data},
|
||||
'TaskLlmUltravox_S2S:processToolOutput - invalid tool output, must be client_tool_result');
|
||||
}
|
||||
else {
|
||||
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(data)]);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err, data}, 'TaskLlmUltravox_S2S:processToolOutput - Error processing tool output');
|
||||
}
|
||||
}
|
||||
|
||||
_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 = ultravox_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
|
||||
}, 'TaskLlmUltravox_S2S:_populateEvents');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskLlmUltravox_S2S;
|
||||
352
lib/tasks/llm/llms/voice_agent_s2s.js
Normal file
352
lib/tasks/llm/llms/voice_agent_s2s.js
Normal file
@@ -0,0 +1,352 @@
|
||||
const Task = require('../../task');
|
||||
const TaskName = 'Llm_VoiceAgent_s2s';
|
||||
const {LlmEvents_VoiceAgent} = require('../../../utils/constants');
|
||||
const ClientEvent = 'client.event';
|
||||
const SessionDelete = 'session.delete';
|
||||
|
||||
const va_server_events = [
|
||||
'Error',
|
||||
'Welcome',
|
||||
'SettingsApplied',
|
||||
'ConversationText',
|
||||
'UserStartedSpeaking',
|
||||
'EndOfThought',
|
||||
'AgentThinking',
|
||||
'FunctionCallRequest',
|
||||
'FunctionCalling',
|
||||
'AgentStartedSpeaking',
|
||||
'AgentAudioDone',
|
||||
];
|
||||
|
||||
const expandWildcards = (events) => {
|
||||
// no-op for deepgram
|
||||
return events;
|
||||
};
|
||||
|
||||
class TaskLlmVoiceAgent_S2S extends Task {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts, parentTask);
|
||||
this.parent = parentTask;
|
||||
|
||||
this.vendor = this.parent.vendor;
|
||||
this.model = this.parent.model || 'voice-agent';
|
||||
this.auth = this.parent.auth;
|
||||
this.connectionOptions = this.parent.connectOptions;
|
||||
|
||||
const {apiKey} = this.auth || {};
|
||||
if (!apiKey) throw new Error('auth.apiKey is required for VoiceAgent S2S');
|
||||
|
||||
this.apiKey = apiKey;
|
||||
this.authType = 'bearer';
|
||||
this.actionHook = this.data.actionHook;
|
||||
this.eventHook = this.data.eventHook;
|
||||
this.toolHook = this.data.toolHook;
|
||||
const {Settings} = this.data.llmOptions;
|
||||
|
||||
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} = Settings;
|
||||
const cfg = this.Settings = rest;
|
||||
|
||||
if (!cfg.agent) throw new Error('llmOptions.Settings.agent is required for VoiceAgent S2S');
|
||||
if (!cfg.agent.think) {
|
||||
throw new Error('llmOptions.Settings.agent.think 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.Settings.agent.think.provider.type is required for VoiceAgent S2S');
|
||||
}
|
||||
|
||||
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 || va_server_events);
|
||||
|
||||
this.addCustomEventListener = parentTask.addCustomEventListener.bind(parentTask);
|
||||
this.removeCustomEventListeners = parentTask.removeCustomEventListeners.bind(parentTask);
|
||||
}
|
||||
|
||||
get name() { return TaskName; }
|
||||
|
||||
get host() {
|
||||
const {host} = this.connectionOptions || {};
|
||||
return host || 'agent.deepgram.com';
|
||||
}
|
||||
|
||||
get path() {
|
||||
const {path} = this.connectionOptions || {};
|
||||
if (path) return path;
|
||||
|
||||
return '/v1/agent/converse';
|
||||
}
|
||||
|
||||
async _api(ep, args) {
|
||||
const res = await ep.api('uuid_voice_agent_s2s', `^^|${args.join('|')}`);
|
||||
if (!res.body?.startsWith('+OK')) {
|
||||
throw new Error(`Error calling uuid_voice_agent_s2s: ${JSON.stringify(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}, 'TaskLlmVoiceAgent_S2S:kill - error deleting session'));
|
||||
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send function call response to the VoiceAgent server
|
||||
*/
|
||||
async processToolOutput(ep, tool_call_id, data) {
|
||||
try {
|
||||
const {data:response} = data;
|
||||
this.logger.debug({tool_call_id, response}, 'TaskLlmVoiceAgent_S2S:processToolOutput');
|
||||
|
||||
if (!response.type || response.type !== 'FunctionCallResponse') {
|
||||
this.logger.info({response},
|
||||
'TaskLlmVoiceAgent_S2S:processToolOutput - invalid tool output, must be FunctionCallResponse');
|
||||
}
|
||||
else {
|
||||
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(response)]);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'TaskLlmVoiceAgent_S2S:processToolOutput');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a session.update to the VoiceAgent server
|
||||
* Note: creating and deleting conversation items also supported as well as interrupting the assistant
|
||||
*/
|
||||
async processLlmUpdate(ep, data, _callSid) {
|
||||
try {
|
||||
this.logger.debug({data, _callSid}, 'TaskLlmVoiceAgent_S2S:processLlmUpdate');
|
||||
|
||||
if (!data.type || ![
|
||||
'UpdateInstructions',
|
||||
'UpdateSpeak',
|
||||
'InjectAgentMessage',
|
||||
].includes(data.type)) {
|
||||
this.logger.info({data}, 'TaskLlmVoiceAgent_S2S:processLlmUpdate - invalid mid-call request');
|
||||
}
|
||||
else {
|
||||
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(data)]);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'TaskLlmVoiceAgent_S2S:processLlmUpdate');
|
||||
}
|
||||
}
|
||||
|
||||
async _startListening(cs, ep) {
|
||||
this._registerHandlers(ep);
|
||||
|
||||
try {
|
||||
const args = [ep.uuid, 'session.create', this.host, this.path, this.authType, this.apiKey];
|
||||
await this._api(ep, args);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, `TaskLlmVoiceAgent_S2S:_startListening: ${JSON.stringify(err)}`);
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
async _sendClientEvent(ep, obj) {
|
||||
let ok = true;
|
||||
this.logger.debug({obj}, 'TaskLlmVoiceAgent_S2S:_sendClientEvent');
|
||||
try {
|
||||
const args = [ep.uuid, ClientEvent, JSON.stringify(obj)];
|
||||
await this._api(ep, args);
|
||||
} catch (err) {
|
||||
ok = false;
|
||||
this.logger.error({err}, 'TaskLlmVoiceAgent_S2S:_sendClientEvent - Error');
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
async _sendInitialMessage(ep) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
_registerHandlers(ep) {
|
||||
this.addCustomEventListener(ep, LlmEvents_VoiceAgent.Connect, this._onConnect.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_VoiceAgent.ConnectFailure, this._onConnectFailure.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_VoiceAgent.Disconnect, this._onDisconnect.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_VoiceAgent.ServerEvent, this._onServerEvent.bind(this, ep));
|
||||
}
|
||||
|
||||
_unregisterHandlers() {
|
||||
this.removeCustomEventListeners();
|
||||
}
|
||||
|
||||
_onError(_ep, evt) {
|
||||
this.logger.info({evt}, 'TaskLlmVoiceAgent_S2S:_onError');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
_onConnect(ep) {
|
||||
this.logger.debug('TaskLlmVoiceAgent_S2S:_onConnect');
|
||||
this._sendInitialMessage(ep);
|
||||
}
|
||||
_onConnectFailure(_ep, evt) {
|
||||
this.logger.info(evt, 'TaskLlmVoiceAgent_S2S:_onConnectFailure');
|
||||
this.results = {completionReason: 'connection failure'};
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
_onDisconnect(_ep, evt) {
|
||||
this.logger.info(evt, 'TaskLlmVoiceAgent_S2S:_onConnectFailure');
|
||||
this.results = {completionReason: 'disconnect from remote end'};
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
async _onServerEvent(_ep, evt) {
|
||||
let endConversation = false;
|
||||
const type = evt.type;
|
||||
this.logger.info({evt}, 'TaskLlmVoiceAgent_S2S:_onServerEvent');
|
||||
|
||||
/* check for failures, such as rate limit exceeded, that should terminate the conversation */
|
||||
if (type === 'response.done' && evt.response.status === 'failed') {
|
||||
endConversation = true;
|
||||
this.results = {
|
||||
completionReason: 'server failure',
|
||||
error: evt.response.status_details?.error
|
||||
};
|
||||
}
|
||||
|
||||
/* server errors of some sort */
|
||||
else if (type === 'error') {
|
||||
endConversation = true;
|
||||
this.results = {
|
||||
completionReason: 'server error',
|
||||
error: evt.error
|
||||
};
|
||||
}
|
||||
|
||||
/* tool calls */
|
||||
else if (type === 'FunctionCallRequest') {
|
||||
this.logger.debug({evt}, 'TaskLlmVoiceAgent_S2S:_onServerEvent - function_call');
|
||||
|
||||
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 {functions} = evt;
|
||||
const handledFunctions = [];
|
||||
|
||||
try {
|
||||
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_S2S:_onServerEvent - error calling function');
|
||||
this.results = {
|
||||
completionReason: 'client error calling function',
|
||||
error: err
|
||||
};
|
||||
endConversation = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 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}, 'TaskLlmVoiceAgent_S2S:_onServerEvent - error sending event hook'));
|
||||
}
|
||||
|
||||
if (endConversation) {
|
||||
this.logger.info({results: this.results},
|
||||
'TaskLlmVoiceAgent_S2S:_onServerEvent - ending conversation due to error');
|
||||
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 = va_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
|
||||
}, 'TaskLlmVoiceAgent_S2S:_populateEvents');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskLlmVoiceAgent_S2S;
|
||||
@@ -1,4 +1,4 @@
|
||||
const Task = require('./task');
|
||||
const { validateVerb } = require('@jambonz/verb-specifications');
|
||||
const {TaskName} = require('../utils/constants');
|
||||
const errBadInstruction = new Error('malformed jambonz application payload');
|
||||
|
||||
@@ -9,21 +9,65 @@ function makeTask(logger, obj, parent) {
|
||||
}
|
||||
const name = keys[0];
|
||||
const data = obj[name];
|
||||
//logger.debug(data, `makeTask: ${name}`);
|
||||
if (typeof data !== 'object') {
|
||||
throw errBadInstruction;
|
||||
}
|
||||
Task.validate(name, data);
|
||||
validateVerb(name, data, logger);
|
||||
switch (name) {
|
||||
case TaskName.Answer:
|
||||
const TaskAnswer = require('./answer');
|
||||
return new TaskAnswer(logger, data, parent);
|
||||
case TaskName.SipDecline:
|
||||
const TaskSipDecline = require('./sip_decline');
|
||||
return new TaskSipDecline(logger, data, parent);
|
||||
case TaskName.SipRequest:
|
||||
const TaskSipRequest = require('./sip_request');
|
||||
return new TaskSipRequest(logger, data, parent);
|
||||
case TaskName.SipRefer:
|
||||
const TaskSipRefer = require('./sip_refer');
|
||||
return new TaskSipRefer(logger, data, parent);
|
||||
case TaskName.Config:
|
||||
const TaskConfig = require('./config');
|
||||
return new TaskConfig(logger, data, parent);
|
||||
case TaskName.Conference:
|
||||
const TaskConference = require('./conference');
|
||||
return new TaskConference(logger, data, parent);
|
||||
case TaskName.Dial:
|
||||
const TaskDial = require('./dial');
|
||||
return new TaskDial(logger, data, parent);
|
||||
case TaskName.Dialogflow:
|
||||
const TaskDialogflow = require('./dialogflow');
|
||||
return new TaskDialogflow(logger, data, parent);
|
||||
case TaskName.Dequeue:
|
||||
const TaskDequeue = require('./dequeue');
|
||||
return new TaskDequeue(logger, data, parent);
|
||||
case TaskName.Dtmf:
|
||||
const TaskDtmf = require('./dtmf');
|
||||
return new TaskDtmf(logger, data, parent);
|
||||
case TaskName.Dub:
|
||||
const TaskDub = require('./dub');
|
||||
return new TaskDub(logger, data, parent);
|
||||
case TaskName.Enqueue:
|
||||
const TaskEnqueue = require('./enqueue');
|
||||
return new TaskEnqueue(logger, data, parent);
|
||||
case TaskName.Hangup:
|
||||
const TaskHangup = require('./hangup');
|
||||
return new TaskHangup(logger, data, parent);
|
||||
case TaskName.Leave:
|
||||
const TaskLeave = require('./leave');
|
||||
return new TaskLeave(logger, data, parent);
|
||||
case TaskName.Lex:
|
||||
const TaskLex = require('./lex');
|
||||
return new TaskLex(logger, data, parent);
|
||||
case TaskName.Message:
|
||||
const TaskMessage = require('./message');
|
||||
return new TaskMessage(logger, data, parent);
|
||||
case TaskName.Llm:
|
||||
const TaskLlm = require('./llm');
|
||||
return new TaskLlm(logger, data, parent);
|
||||
case TaskName.Rasa:
|
||||
const TaskRasa = require('./rasa');
|
||||
return new TaskRasa(logger, data, parent);
|
||||
case TaskName.Say:
|
||||
const TaskSay = require('./say');
|
||||
return new TaskSay(logger, data, parent);
|
||||
@@ -40,6 +84,7 @@ function makeTask(logger, obj, parent) {
|
||||
const TaskTranscribe = require('./transcribe');
|
||||
return new TaskTranscribe(logger, data, parent);
|
||||
case TaskName.Listen:
|
||||
case TaskName.Stream:
|
||||
const TaskListen = require('./listen');
|
||||
return new TaskListen(logger, data, parent);
|
||||
case TaskName.Redirect:
|
||||
|
||||
127
lib/tasks/message.js
Normal file
127
lib/tasks/message.js
Normal file
@@ -0,0 +1,127 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
const bent = require('bent');
|
||||
const crypto = require('crypto');
|
||||
const {K8S} = require('../config');
|
||||
class TaskMessage extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.None;
|
||||
|
||||
this.payload = {
|
||||
message_sid: this.data.message_sid || crypto.randomUUID(),
|
||||
carrier: this.data.carrier,
|
||||
to: this.data.to,
|
||||
from: this.data.from,
|
||||
text: this.data.text
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
get name() { return TaskName.Message; }
|
||||
|
||||
/**
|
||||
* Send outbound SMS
|
||||
*/
|
||||
async exec(cs) {
|
||||
const {srf, accountSid} = cs;
|
||||
const {res} = cs.callInfo;
|
||||
let payload = this.payload;
|
||||
const actionParams = {message_sid: this.payload.message_sid};
|
||||
|
||||
await super.exec(cs);
|
||||
try {
|
||||
const {getSmpp, dbHelpers} = srf.locals;
|
||||
const {lookupSmppGateways} = dbHelpers;
|
||||
|
||||
this.logger.debug(`looking up gateways for account_sid: ${accountSid}`);
|
||||
const r = await lookupSmppGateways(accountSid);
|
||||
let gw, url, relativeUrl;
|
||||
if (r.length > 0) {
|
||||
gw = r.find((o) => 1 === o.sg.outbound && (!this.payload.carrier || o.vc.name === this.payload.carrier));
|
||||
}
|
||||
if (gw) {
|
||||
this.logger.info({gw, accountSid}, 'Message:exec - using smpp to send message');
|
||||
url = K8S ? 'http://smpp' : getSmpp();
|
||||
relativeUrl = '/sms';
|
||||
payload = {
|
||||
...payload,
|
||||
...gw.sg,
|
||||
...gw.vc
|
||||
};
|
||||
}
|
||||
else {
|
||||
//TMP: smpp only at the moment, need to add http back in
|
||||
/*
|
||||
this.logger.info({gw, accountSid, carrier: this.payload.carrier},
|
||||
'Message:exec - no smpp gateways found to send message');
|
||||
relativeUrl = 'v1/outboundSMS';
|
||||
const sbcAddress = getSBC();
|
||||
if (sbcAddress) url = `http://${sbcAddress}:3000/`;
|
||||
*/
|
||||
this.performAction({
|
||||
...actionParams,
|
||||
message_status: 'no carriers'
|
||||
}).catch((err) => {});
|
||||
if (res) res.sendStatus(404);
|
||||
return;
|
||||
}
|
||||
if (url) {
|
||||
const post = bent(url, 'POST', 'json', 201, 480);
|
||||
this.logger.info({payload, url}, 'Message:exec sending outbound SMS');
|
||||
const response = await post(relativeUrl, payload);
|
||||
const {smpp_err_code, carrier, message_id, message} = response;
|
||||
if (smpp_err_code) {
|
||||
this.logger.info({response}, 'SMPP error sending SMS');
|
||||
this.performAction({
|
||||
...actionParams,
|
||||
carrier,
|
||||
carrier_message_id: message_id,
|
||||
message_status: 'failure',
|
||||
message_failure_reason: message
|
||||
}).catch((err) => {});
|
||||
if (res) {
|
||||
res.status(480).json({
|
||||
...response,
|
||||
sid: cs.callInfo.messageSid
|
||||
});
|
||||
}
|
||||
}
|
||||
else {
|
||||
const {message_id, carrier} = response;
|
||||
this.logger.info({response}, 'Successfully sent SMS');
|
||||
this.performAction({
|
||||
...actionParams,
|
||||
carrier,
|
||||
carrier_message_id: message_id,
|
||||
message_status: 'success',
|
||||
}).catch((err) => {});
|
||||
if (res) {
|
||||
res.status(200).json({
|
||||
sid: cs.callInfo.messageSid,
|
||||
carrierResponse: response
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.logger.info('Message:exec - unable to send SMS as SMPP is not configured on the system');
|
||||
this.performAction({
|
||||
...actionParams,
|
||||
message_status: 'smpp configuration error'
|
||||
}).catch((err) => {});
|
||||
if (res) res.status(404).json({message: 'no configured SMS gateways'});
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'TaskMessage:exec - unexpected error sending SMS');
|
||||
this.performAction({
|
||||
...actionParams,
|
||||
message_status: 'system error',
|
||||
message_failure_reason: err.message
|
||||
});
|
||||
if (res) res.status(422).json({message: 'no configured SMS gateways'});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskMessage;
|
||||
@@ -10,14 +10,14 @@ class TaskPause extends Task {
|
||||
|
||||
get name() { return TaskName.Pause; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
super.exec(cs);
|
||||
async exec(cs) {
|
||||
await super.exec(cs);
|
||||
this.timer = setTimeout(this.notifyTaskDone.bind(this), this.length * 1000);
|
||||
await this.awaitTaskDone();
|
||||
}
|
||||
|
||||
async kill() {
|
||||
super.kill();
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
clearTimeout(this.timer);
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
@@ -1,36 +1,118 @@
|
||||
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;
|
||||
this.seekOffset = this.data.seekOffset || -1;
|
||||
this.timeoutSecs = this.data.timeoutSecs || -1;
|
||||
this.loop = this.data.loop || 1;
|
||||
this.earlyMedia = this.data.earlyMedia === true;
|
||||
}
|
||||
|
||||
get name() { return TaskName.Play; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
super.exec(cs);
|
||||
get summary() {
|
||||
return `${this.name}:{url=${this.url}}`;
|
||||
}
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
this.ep = ep;
|
||||
let timeout;
|
||||
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;
|
||||
try {
|
||||
await this.kill(cs);
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'Error killing audio on timeoutSecs');
|
||||
}
|
||||
}, this.timeoutSecs * 1000);
|
||||
}
|
||||
try {
|
||||
while (!this.killed && this.loop--) {
|
||||
await ep.play(this.url);
|
||||
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)) {
|
||||
for (const playUrl of this.url) {
|
||||
await this.playToConfMember(this.ep, memberId, confName, confUuid, playUrl);
|
||||
}
|
||||
} else {
|
||||
await this.playToConfMember(this.ep, memberId, confName, confUuid, this.url);
|
||||
}
|
||||
} else {
|
||||
let file = this.url;
|
||||
if (this.seekOffset >= 0) {
|
||||
file = {file: this.url, seekOffset: this.seekOffset};
|
||||
this.seekOffset = -1;
|
||||
}
|
||||
const result = await ep.play(file);
|
||||
playbackSeconds += parseInt(result.playbackSeconds);
|
||||
playbackMilliseconds += parseInt(result.playbackMilliseconds);
|
||||
if (this.killed || !this.loop || completed) {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
await this.performAction(
|
||||
Object.assign(result, {reason: 'playCompleted', playbackSeconds, playbackMilliseconds}),
|
||||
!(this.parentTask || cs.isConfirmCallSession));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info(err, `TaskPlay:exec - error playing ${this.url}`);
|
||||
this.logger.info(`TaskPlay:exec - error playing ${this.url}: ${err.message}`);
|
||||
this.playComplete = true;
|
||||
if (err.message === 'File Not Found') {
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
await this.performAction({status: 'fail', reason: 'playFailed'}, !(this.parentTask || cs.isConfirmCallSession));
|
||||
this.emit('playDone');
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.PLAY_FILENOTFOUND,
|
||||
url: this.url,
|
||||
target_sid: cs.callSid
|
||||
});
|
||||
throw new PlayFileNotFoundError(this.url);
|
||||
}
|
||||
}
|
||||
this.emit('playDone');
|
||||
}
|
||||
|
||||
async kill() {
|
||||
super.kill();
|
||||
if (this.ep.connected && !this.playComplete) {
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
if (this.ep?.connected && !this.playComplete) {
|
||||
this.logger.debug('TaskPlay:kill - killing audio');
|
||||
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
if (cs.isInConference) {
|
||||
const {memberId, confName} = cs;
|
||||
this.killPlayToConfMember(this.ep, memberId, confName);
|
||||
}
|
||||
else {
|
||||
//this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
cs.stickyEventEmitter.emit('uuid_break', this);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
170
lib/tasks/rasa.js
Normal file
170
lib/tasks/rasa.js
Normal file
@@ -0,0 +1,170 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
const makeTask = require('./make_task');
|
||||
const bent = require('bent');
|
||||
|
||||
class Rasa extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
this.prompt = this.data.prompt;
|
||||
this.eventHook = this.data?.eventHook;
|
||||
this.actionHook = this.data?.actionHook;
|
||||
this.post = bent('POST', 'json', 200);
|
||||
}
|
||||
|
||||
get name() { return TaskName.Rasa; }
|
||||
|
||||
get hasReportedFinalAction() {
|
||||
return this.reportedFinalAction || this.isReplacingApplication;
|
||||
}
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
|
||||
this.ep = ep;
|
||||
try {
|
||||
/* set event handlers */
|
||||
this.on('transcription', this._onTranscription.bind(this, cs, ep));
|
||||
this.on('timeout', this._onTimeout.bind(this, cs, ep));
|
||||
|
||||
/* start the first gather */
|
||||
this.gatherTask = this._makeGatherTask(this.prompt);
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.gatherTask.summary}`);
|
||||
this.gatherTask.span = span;
|
||||
this.gatherTask.ctx = ctx;
|
||||
this.gatherTask.exec(cs, {ep})
|
||||
.then(() => span.end())
|
||||
.catch((err) => {
|
||||
span.end();
|
||||
this.logger.info({err}, 'Rasa gather task returned error');
|
||||
});
|
||||
|
||||
await this.awaitTaskDone();
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'Rasa error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
this.logger.debug('Rasa:kill');
|
||||
|
||||
if (!this.hasReportedFinalAction) {
|
||||
this.reportedFinalAction = true;
|
||||
this.performAction({rasaResult: 'caller hungup'})
|
||||
.catch((err) => this.logger.info({err}, 'rasa - error w/ action webook'));
|
||||
}
|
||||
|
||||
if (this.ep.connected) {
|
||||
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
}
|
||||
this.removeAllListeners();
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
_makeGatherTask(prompt) {
|
||||
let opts = {
|
||||
input: ['speech'],
|
||||
timeout: this.data.timeout || 10,
|
||||
recognizer: this.data.recognizer || {
|
||||
vendor: 'default',
|
||||
language: 'default'
|
||||
}
|
||||
};
|
||||
if (prompt) {
|
||||
const sayOpts = this.data.tts ?
|
||||
{text: prompt, synthesizer: this.data.tts} :
|
||||
{text: prompt};
|
||||
|
||||
opts = {
|
||||
...opts,
|
||||
say: sayOpts
|
||||
};
|
||||
}
|
||||
//this.logger.debug({opts}, 'constructing a nested gather object');
|
||||
const gather = makeTask(this.logger, {gather: opts}, this);
|
||||
return gather;
|
||||
}
|
||||
|
||||
async _onTranscription(cs, ep, evt) {
|
||||
//this.logger.debug({evt}, `Rasa: got transcription for callSid ${cs.callSid}`);
|
||||
const utterance = evt.alternatives[0].transcript;
|
||||
|
||||
if (this.eventHook) {
|
||||
this.performHook(cs, this.eventHook, {event: 'userMessage', message: utterance})
|
||||
.then((redirected) => {
|
||||
if (redirected) {
|
||||
this.logger.info('Rasa_onTranscription: event handler for user message redirected us to new webhook');
|
||||
this.reportedFinalAction = true;
|
||||
this.performAction({rasaResult: 'redirect'}, false);
|
||||
if (this.gatherTask) this.gatherTask.kill(cs);
|
||||
}
|
||||
return;
|
||||
})
|
||||
.catch(({err}) => {
|
||||
this.logger.info({err}, 'Rasa_onTranscription: error sending event hook');
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
sender: cs.callSid,
|
||||
message: utterance
|
||||
};
|
||||
this.logger.debug({payload}, 'Rasa:_onTranscription - sending payload to Rasa');
|
||||
const response = await this.post(this.data.url, payload);
|
||||
this.logger.debug({response}, 'Rasa:_onTranscription - got response from Rasa');
|
||||
const botUtterance = Array.isArray(response) ?
|
||||
response.reduce((prev, current) => {
|
||||
return current.text ? `${prev} ${current.text}` : '';
|
||||
}, '') :
|
||||
null;
|
||||
if (botUtterance) {
|
||||
this.logger.debug({botUtterance}, 'Rasa:_onTranscription: got user utterance');
|
||||
this.gatherTask = this._makeGatherTask(botUtterance);
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.gatherTask.summary}`);
|
||||
this.gatherTask.span = span;
|
||||
this.gatherTask.ctx = ctx;
|
||||
this.gatherTask.exec(cs, {ep})
|
||||
.then(() => span.end())
|
||||
.catch((err) => {
|
||||
span.end();
|
||||
this.logger.info({err}, 'Rasa gather task returned error');
|
||||
});
|
||||
if (this.eventHook) {
|
||||
this.performHook(cs, this.eventHook, {event: 'botMessage', message: response})
|
||||
.then((redirected) => {
|
||||
if (redirected) {
|
||||
this.logger.info('Rasa_onTranscription: event handler for bot message redirected us to new webhook');
|
||||
this.reportedFinalAction = true;
|
||||
this.performAction({rasaResult: 'redirect'}, false);
|
||||
if (this.gatherTask) this.gatherTask.kill(cs);
|
||||
}
|
||||
return;
|
||||
})
|
||||
.catch(({err}) => {
|
||||
this.logger.info({err}, 'Rasa_onTranscription: error sending event hook');
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'Rasa_onTranscription: Error sending user utterance to Rasa - ending task');
|
||||
this.performAction({rasaResult: 'webhookError'});
|
||||
this.reportedFinalAction = true;
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
_onTimeout(cs, ep, evt) {
|
||||
this.logger.debug({evt}, 'Rasa: got timeout');
|
||||
if (!this.hasReportedFinalAction) this.performAction({rasaResult: 'timeout'});
|
||||
this.reportedFinalAction = true;
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
module.exports = Rasa;
|
||||
@@ -1,5 +1,8 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName} = require('../utils/constants');
|
||||
const WsRequestor = require('../utils/ws-requestor');
|
||||
const URL = require('url');
|
||||
const HttpRequestor = require('../utils/http-requestor');
|
||||
|
||||
/**
|
||||
* Redirects to a new application
|
||||
@@ -12,7 +15,33 @@ class TaskRedirect extends Task {
|
||||
get name() { return TaskName.Redirect; }
|
||||
|
||||
async exec(cs) {
|
||||
super.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) {
|
||||
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;
|
||||
} catch (err) {
|
||||
this.logger.info(err, `Task:redirect error updating base url to ${this.actionHook}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.performAction();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName} = require('../utils/constants');
|
||||
const makeTask = require('./make_task');
|
||||
const normalizeJamones = require('../utils/normalize-jamones');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
|
||||
/**
|
||||
* Manages an outdial made via REST API
|
||||
@@ -11,9 +11,14 @@ class TaskRestDial extends Task {
|
||||
super(logger, opts);
|
||||
|
||||
this.from = this.data.from;
|
||||
this.callerName = this.data.callerName;
|
||||
this.timeLimit = this.data.timeLimit;
|
||||
this.fromHost = this.data.fromHost;
|
||||
this.to = this.data.to;
|
||||
this.call_hook = this.data.call_hook;
|
||||
this.timeout = this.data.timeout || 60;
|
||||
this.sipRequestWithinDialogHook = this.data.sipRequestWithinDialogHook;
|
||||
this.referHook = this.data.referHook;
|
||||
|
||||
this.on('connect', this._onConnect.bind(this));
|
||||
this.on('callStatus', this._onCallStatus.bind(this));
|
||||
@@ -21,37 +26,90 @@ class TaskRestDial extends Task {
|
||||
|
||||
get name() { return TaskName.RestDial; }
|
||||
|
||||
set appJson(app_json) {
|
||||
this.app_json = app_json;
|
||||
}
|
||||
|
||||
/**
|
||||
* INVITE has just been sent at this point
|
||||
*/
|
||||
async exec(cs, req) {
|
||||
super.exec(cs);
|
||||
this.req = req;
|
||||
async exec(cs) {
|
||||
await super.exec(cs);
|
||||
this.cs = cs;
|
||||
this.canCancel = true;
|
||||
|
||||
if (this.data.amd) {
|
||||
this.startAmd = cs.startAmd;
|
||||
this.on('amd', this._onAmdEvent.bind(this, cs));
|
||||
}
|
||||
this.stopAmd = cs.stopAmd;
|
||||
|
||||
this._setCallTimer();
|
||||
await this.awaitTaskDone();
|
||||
}
|
||||
|
||||
kill() {
|
||||
super.kill();
|
||||
turnOffAmd() {
|
||||
if (this.callSession.ep && this.callSession.ep.amd) this.stopAmd(this.callSession.ep, this);
|
||||
}
|
||||
|
||||
kill(cs) {
|
||||
super.kill(cs);
|
||||
this._clearCallTimer();
|
||||
if (this.req) {
|
||||
this.req.cancel();
|
||||
this.req = null;
|
||||
if (this.canCancel) {
|
||||
this.canCancel = false;
|
||||
cs?.req?.cancel();
|
||||
}
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
async _onConnect(dlg) {
|
||||
this.req = null;
|
||||
this.canCancel = false;
|
||||
const cs = this.callSession;
|
||||
cs.setDialog(dlg);
|
||||
|
||||
cs.referHook = this.referHook;
|
||||
if (this.timeLimit) {
|
||||
cs.startMaxCallDurationTimer(this.timeLimit);
|
||||
}
|
||||
this.logger.debug('TaskRestDial:_onConnect - call connected');
|
||||
if (this.sipRequestWithinDialogHook) this._initSipRequestWithinDialogHandler(cs, dlg);
|
||||
try {
|
||||
const tasks = await cs.requestor.request(this.call_hook, cs.callInfo);
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
const params = {
|
||||
...(cs.callInfo.toJSON()),
|
||||
...(this.env_vars && {env_vars: this.env_vars}),
|
||||
defaults: {
|
||||
synthesizer: {
|
||||
vendor: cs.speechSynthesisVendor,
|
||||
language: cs.speechSynthesisLanguage,
|
||||
voice: cs.speechSynthesisVoice,
|
||||
label: cs.speechSynthesisLabel,
|
||||
},
|
||||
recognizer: {
|
||||
vendor: cs.speechRecognizerVendor,
|
||||
language: cs.speechRecognizerLanguage,
|
||||
label: cs.speechRecognizerLabel,
|
||||
}
|
||||
}
|
||||
};
|
||||
if (this.startAmd) {
|
||||
try {
|
||||
this.startAmd(this.callSession, this.callSession.ep, this, this.data.amd);
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Rest:dial:Call established - Error calling startAmd');
|
||||
}
|
||||
}
|
||||
let tasks;
|
||||
if (this.app_json) {
|
||||
this.logger.debug('TaskRestDial: using app_json from task data');
|
||||
tasks = JSON.parse(this.app_json);
|
||||
} else {
|
||||
this.logger.debug({call_hook: this.call_hook}, 'TaskRestDial: retrieving application');
|
||||
tasks = await cs.requestor.request('session:new', this.call_hook, params, httpHeaders);
|
||||
}
|
||||
if (tasks && Array.isArray(tasks)) {
|
||||
this.logger.debug({tasks: tasks}, `TaskRestDial: replacing application with ${tasks.length} tasks`);
|
||||
cs.replaceApplication(normalizeJamones(this.logger, tasks).map((tdata) => makeTask(this.logger, tdata)));
|
||||
cs.replaceApplication(normalizeJambones(this.logger, tasks).map((tdata) => makeTask(this.logger, tdata)));
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'TaskRestDial:_onConnect error retrieving or parsing application, ending call');
|
||||
@@ -62,7 +120,7 @@ class TaskRestDial extends Task {
|
||||
_onCallStatus(status) {
|
||||
this.logger.debug(`CallStatus: ${status}`);
|
||||
if (status >= 200) {
|
||||
this.req = null;
|
||||
this.canCancel = false;
|
||||
this._clearCallTimer();
|
||||
if (status !== 200) this.notifyTaskDone();
|
||||
}
|
||||
@@ -80,7 +138,29 @@ class TaskRestDial extends Task {
|
||||
_onCallTimeout() {
|
||||
this.logger.debug('TaskRestDial: timeout expired without answer, killing task');
|
||||
this.timer = null;
|
||||
this.kill();
|
||||
if (this.canCancel) {
|
||||
this.canCancel = false;
|
||||
this.cs?.req?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
_onAmdEvent(cs, evt) {
|
||||
this.logger.info({evt}, 'Rest:dial:_onAmdEvent');
|
||||
const {actionHook} = this.data.amd;
|
||||
this.performHook(cs, actionHook, evt)
|
||||
.catch((err) => {
|
||||
this.logger.error({err}, 'Rest:dial:_onAmdEvent - error calling actionHook');
|
||||
});
|
||||
}
|
||||
|
||||
_initSipRequestWithinDialogHandler(cs, dlg) {
|
||||
cs.sipRequestWithinDialogHook = this.sipRequestWithinDialogHook;
|
||||
dlg.on('info', this._onRequestWithinDialog.bind(this, cs));
|
||||
dlg.on('message', this._onRequestWithinDialog.bind(this, cs));
|
||||
}
|
||||
|
||||
async _onRequestWithinDialog(cs, req, res) {
|
||||
cs._onRequestWithinDialog(req, res);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
53
lib/tasks/say-legacy.js
Normal file
53
lib/tasks/say-legacy.js
Normal file
@@ -0,0 +1,53 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
|
||||
class TaskSayLegacy extends Task {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
this.text = this.data.text;
|
||||
this.loop = this.data.loop || 1;
|
||||
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
|
||||
if (this.data.synthesizer) {
|
||||
this.voice = this.data.synthesizer.voice;
|
||||
switch (this.data.synthesizer.vendor) {
|
||||
case 'google':
|
||||
this.ttsEngine = 'google_tts';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unsupported tts vendor ${this.data.synthesizer.vendor}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get name() { return TaskName.SayLegacy; }
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
super.exec(cs);
|
||||
this.ep = ep;
|
||||
try {
|
||||
while (!this.killed && this.loop--) {
|
||||
this.logger.debug(`TaskSayLegacy: remaining loops ${this.loop}`);
|
||||
await ep.speak({
|
||||
ttsEngine: 'google_tts',
|
||||
voice: this.voice || this.callSession.speechSynthesisVoice,
|
||||
text: this.text
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'TaskSayLegacy:exec error');
|
||||
}
|
||||
this.emit('playDone');
|
||||
}
|
||||
|
||||
async kill() {
|
||||
super.kill();
|
||||
if (this.ep.connected) {
|
||||
this.logger.debug('TaskSayLegacy:kill - killing audio');
|
||||
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskSayLegacy;
|
||||
483
lib/tasks/say.js
483
lib/tasks/say.js
@@ -1,53 +1,470 @@
|
||||
const Task = require('./task');
|
||||
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 { sleepFor } = require('../utils/helpers');
|
||||
|
||||
class TaskSay extends Task {
|
||||
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>');
|
||||
const options = {
|
||||
softLimit: 100,
|
||||
hardLimit: chunkSize - 15,
|
||||
extraSplitChars: ',;!?',
|
||||
};
|
||||
pollySSMLSplit.configure(options);
|
||||
try {
|
||||
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 splitting SSML long text');
|
||||
return [text];
|
||||
}
|
||||
};
|
||||
|
||||
const parseTextFromSayString = (text) => {
|
||||
const closingBraceIndex = text.indexOf('}');
|
||||
if (closingBraceIndex === -1) return text;
|
||||
return text.slice(closingBraceIndex + 1);
|
||||
};
|
||||
|
||||
class TaskSay extends TtsTask {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts);
|
||||
super(logger, opts, parentTask);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
this.text = this.data.text;
|
||||
this.loop = this.data.loop || 1;
|
||||
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
|
||||
if (this.data.synthesizer) {
|
||||
this.voice = this.data.synthesizer.voice;
|
||||
switch (this.data.synthesizer.vendor) {
|
||||
case 'google':
|
||||
this.ttsEngine = 'google_tts';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unsupported tts vendor ${this.data.synthesizer.vendor}`);
|
||||
}
|
||||
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;
|
||||
this.closeOnStreamEmpty = this.data.closeOnStreamEmpty !== false;
|
||||
}
|
||||
else {
|
||||
this._isStreamingTts = false;
|
||||
this.loop = this.data.loop || 1;
|
||||
this.isHandledByPrimaryProvider = true;
|
||||
}
|
||||
}
|
||||
|
||||
get name() { return TaskName.Say; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
super.exec(cs);
|
||||
this.ep = ep;
|
||||
try {
|
||||
while (!this.killed && this.loop--) {
|
||||
this.logger.debug(`TaskSay: remaining loops ${this.loop}`);
|
||||
await ep.speak({
|
||||
ttsEngine: 'google_tts',
|
||||
voice: this.voice || this.callSession.speechSynthesisVoice,
|
||||
text: this.text
|
||||
});
|
||||
get summary() {
|
||||
if (this.isStreamingTts) return `${this.name} streaming`;
|
||||
else {
|
||||
for (let i = 0; i < this.text.length; i++) {
|
||||
if (this.text[i].startsWith('silence_stream')) continue;
|
||||
return `${this.name}{text=${this.text[i].slice(0, 15)}${this.text[i].length > 15 ? '...' : ''}}`;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'TaskSay:exec error');
|
||||
return `${this.name}{${this.text[0]}}`;
|
||||
}
|
||||
this.emit('playDone');
|
||||
}
|
||||
|
||||
async kill() {
|
||||
super.kill();
|
||||
if (this.ep.connected) {
|
||||
get isStreamingTts() { return this._isStreamingTts; }
|
||||
|
||||
_validateURL(urlString) {
|
||||
try {
|
||||
new URL(urlString);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async exec(cs, obj) {
|
||||
if (this.isStreamingTts && !cs.appIsUsingWebsockets) {
|
||||
throw new Error('Say: streaming say verb requires applications to use the websocket API');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async handlingStreaming(cs, {ep}) {
|
||||
const {vendor, language, voice, label} = this.getTtsVendorData(cs);
|
||||
const credentials = cs.getSpeechCredentials(vendor, 'tts', label);
|
||||
if (!credentials) {
|
||||
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'})
|
||||
.catch((err) => this.logger.info({err}, 'TaskSay:handlingStreaming - Error sending'));
|
||||
|
||||
//TODO: send tts:streaming-event with error?
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
await this.awaitTaskDone();
|
||||
this.logger.info('TaskSay:handlingStreaming - done');
|
||||
}
|
||||
|
||||
async handling(cs, {ep}) {
|
||||
const {srf, accountSid:account_sid, callSid:target_sid} = cs;
|
||||
const {writeAlerts, AlertType} = srf.locals;
|
||||
const {addFileToCache} = srf.locals.dbHelpers;
|
||||
const engine = this.synthesizer.engine || cs.synthesizer?.engine || 'neural';
|
||||
|
||||
await super.exec(cs);
|
||||
this.ep = ep;
|
||||
|
||||
let vendor = this.synthesizer.vendor && this.synthesizer.vendor !== 'default' ?
|
||||
this.synthesizer.vendor :
|
||||
cs.speechSynthesisVendor;
|
||||
let language = this.synthesizer.language && this.synthesizer.language !== 'default' ?
|
||||
this.synthesizer.language :
|
||||
cs.speechSynthesisLanguage ;
|
||||
let voice = this.synthesizer.voice && this.synthesizer.voice !== 'default' ?
|
||||
this.synthesizer.voice :
|
||||
cs.speechSynthesisVoice;
|
||||
let label = this.taskIncludeSynthesizer ? this.synthesizer.label : cs.speechSynthesisLabel;
|
||||
|
||||
const fallbackVendor = this.synthesizer.fallbackVendor && this.synthesizer.fallbackVendor !== 'default' ?
|
||||
this.synthesizer.fallbackVendor :
|
||||
cs.fallbackSpeechSynthesisVendor;
|
||||
const fallbackLanguage = this.synthesizer.fallbackLanguage && this.synthesizer.fallbackLanguage !== 'default' ?
|
||||
this.synthesizer.fallbackLanguage :
|
||||
cs.fallbackSpeechSynthesisLanguage ;
|
||||
const fallbackVoice = this.synthesizer.fallbackVoice && this.synthesizer.fallbackVoice !== 'default' ?
|
||||
this.synthesizer.fallbackVoice :
|
||||
cs.fallbackSpeechSynthesisVoice;
|
||||
const fallbackLabel = this.taskIncludeSynthesizer ?
|
||||
this.synthesizer.fallbackLabel : cs.fallbackSpeechSynthesisLabel;
|
||||
|
||||
if (cs.hasFallbackTts) {
|
||||
vendor = fallbackVendor;
|
||||
language = fallbackLanguage;
|
||||
voice = fallbackVoice;
|
||||
label = fallbackLabel;
|
||||
}
|
||||
|
||||
const startFallback = async(error) => {
|
||||
if (fallbackVendor && this.isHandledByPrimaryProvider && !cs.hasFallbackTts) {
|
||||
this.notifyError(
|
||||
{ msg: 'TTS error', details:`TTS vendor ${vendor} error: ${error}`, failover: 'in progress'});
|
||||
this.isHandledByPrimaryProvider = false;
|
||||
cs.hasFallbackTts = true;
|
||||
this.logger.info(`Synthesize error, fallback to ${fallbackVendor}`);
|
||||
filepath = await this._synthesizeWithSpecificVendor(cs, ep,
|
||||
{
|
||||
vendor: fallbackVendor,
|
||||
language: fallbackLanguage,
|
||||
voice: fallbackVoice,
|
||||
label: fallbackLabel
|
||||
});
|
||||
} else {
|
||||
this.notifyError(
|
||||
{ msg: 'TTS error', details:`TTS vendor ${vendor} error: ${error}`, failover: 'not available'});
|
||||
throw new SpeechCredentialError(error.message);
|
||||
}
|
||||
};
|
||||
let filepath;
|
||||
try {
|
||||
filepath = await this._synthesizeWithSpecificVendor(cs, ep, {vendor, language, voice, label});
|
||||
} catch (error) {
|
||||
await startFallback(error);
|
||||
}
|
||||
this.notifyStatus({event: 'start-playback'});
|
||||
|
||||
while (!this.killed && (this.loop === 'forever' || this.loop--) && ep?.connected) {
|
||||
let segment = 0;
|
||||
while (!this.killed && segment < filepath.length) {
|
||||
if (cs.isInConference) {
|
||||
const {memberId, confName, confUuid} = cs;
|
||||
await this.playToConfMember(ep, memberId, confName, confUuid, filepath[segment]);
|
||||
}
|
||||
else {
|
||||
const isStreaming = filepath[segment].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)}..`);
|
||||
}
|
||||
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');
|
||||
this.notifyStatus({event: 'stop-playback'});
|
||||
this.notifiedPlayBackStop = true;
|
||||
const tts_error = evt.variable_tts_error;
|
||||
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) || 200;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (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 (!tts_error && response_code < 300 && 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,
|
||||
instructions: this.instructions
|
||||
}).catch((err) => this.logger.info({err}, 'Error adding file to cache'));
|
||||
}
|
||||
|
||||
if (this._playResolve) {
|
||||
(tts_error || response_code >= 300) ? this._playReject(new Error(evt.variable_tts_error)) :
|
||||
this._playResolve();
|
||||
}
|
||||
});
|
||||
// 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 {
|
||||
// wait for playback-stop event received to confirm if the playback is successful
|
||||
await this._playPromise;
|
||||
} catch (err) {
|
||||
try {
|
||||
await startFallback(err);
|
||||
continue;
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Error waiting for playback-stop event');
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
this._playPromise = null;
|
||||
this._playResolve = null;
|
||||
this._playReject = null;
|
||||
}
|
||||
if (filepath[segment].startsWith('say:{')) {
|
||||
const arr = /^say:\{.*\}\s*(.*)$/.exec(filepath[segment]);
|
||||
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]}`);
|
||||
}
|
||||
}
|
||||
segment++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
if (this.ep?.connected) {
|
||||
this.logger.debug('TaskSay:kill - killing audio');
|
||||
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
if (cs.isInConference) {
|
||||
const {memberId, confName} = cs;
|
||||
this.killPlayToConfMember(this.ep, memberId, confName);
|
||||
} else if (this.isStreamingTts) {
|
||||
this.logger.debug('TaskSay:kill - clearing TTS stream for streaming audio');
|
||||
cs.clearTtsStream();
|
||||
} else {
|
||||
if (!this.notifiedPlayBackStop) {
|
||||
this.notifyStatus({event: 'stop-playback'});
|
||||
}
|
||||
this.notifyStatus({event: 'kill-playback'});
|
||||
this.ep.api('uuid_break', this.ep.uuid);
|
||||
}
|
||||
this.ep.removeAllListeners('playback-start');
|
||||
this.ep.removeAllListeners('playback-stop');
|
||||
// if we are waiting for playback-stop event, resolve the promise
|
||||
if (this._playResolve) {
|
||||
this._playResolve();
|
||||
this._playResolve = null;
|
||||
}
|
||||
}
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
_addStreamingTtsAttributes(span, evt, vendor) {
|
||||
const attrs = {'tts.cached': false};
|
||||
for (const [key, value] of Object.entries(evt)) {
|
||||
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('verbio_', 'verbio.')
|
||||
.replace('elevenlabs_', 'elevenlabs.');
|
||||
if (spanMapping[newKey]) newKey = spanMapping[newKey];
|
||||
attrs[newKey] = value;
|
||||
if (key === 'variable_tts_time_to_first_byte_ms' && value) {
|
||||
this.cs.srf.locals.stats.histogram('tts.response_time', value, [`vendor:${vendor}`]);
|
||||
}
|
||||
}
|
||||
}
|
||||
delete attrs['cache_filename']; //no value in adding this to the span
|
||||
span.setAttributes(attrs);
|
||||
}
|
||||
|
||||
notifyTtsStreamIsEmpty() {
|
||||
if (this.isStreamingTts && this.closeOnStreamEmpty) {
|
||||
this.logger.info('TaskSay:notifyTtsStreamIsEmpty - stream is empty, killing task');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const spanMapping = {
|
||||
// IMPORTANT!!! JAMBONZ WEBAPP WILL SHOW TEXT PERFECTLY IF THE SPAN NAME IS SMALLER OR EQUAL 25 CHARACTERS.
|
||||
// EX: whisper.ratelim_reqs has length 20 <= 25 which is perfect
|
||||
// Elevenlabs
|
||||
'elevenlabs.reported_latency_ms': 'elevenlabs.latency_ms',
|
||||
'elevenlabs.request_id': 'elevenlabs.req_id',
|
||||
'elevenlabs.history_item_id': 'elevenlabs.item_id',
|
||||
'elevenlabs.optimize_streaming_latency': 'elevenlabs.optimization',
|
||||
'elevenlabs.name_lookup_time_ms': 'name_lookup_ms',
|
||||
'elevenlabs.connect_time_ms': 'connect_ms',
|
||||
'elevenlabs.final_response_time_ms': 'final_response_ms',
|
||||
// Whisper
|
||||
'whisper.reported_latency_ms': 'whisper.latency_ms',
|
||||
'whisper.request_id': 'whisper.req_id',
|
||||
'whisper.reported_organization': 'whisper.organization',
|
||||
'whisper.reported_ratelimit_requests': 'whisper.ratelimit',
|
||||
'whisper.reported_ratelimit_remaining_requests': 'whisper.ratelimit_remain',
|
||||
'whisper.reported_ratelimit_reset_requests': 'whisper.ratelimit_reset',
|
||||
'whisper.name_lookup_time_ms': 'name_lookup_ms',
|
||||
'whisper.connect_time_ms': 'connect_ms',
|
||||
'whisper.final_response_time_ms': 'final_response_ms',
|
||||
// Deepgram
|
||||
'deepgram.request_id': 'deepgram.req_id',
|
||||
'deepgram.reported_model_name': 'deepgram.model_name',
|
||||
'deepgram.reported_model_uuid': 'deepgram.model_uuid',
|
||||
'deepgram.reported_char_count': 'deepgram.char_count',
|
||||
'deepgram.name_lookup_time_ms': 'name_lookup_ms',
|
||||
'deepgram.connect_time_ms': 'connect_ms',
|
||||
'deepgram.final_response_time_ms': 'final_response_ms',
|
||||
// Playht
|
||||
'playht.request_id': 'playht.req_id',
|
||||
'playht.name_lookup_time_ms': 'name_lookup_ms',
|
||||
'playht.connect_time_ms': 'connect_ms',
|
||||
'playht.final_response_time_ms': 'final_response_ms',
|
||||
// Cartesia
|
||||
'cartesia.request_id': 'cartesia.req_id',
|
||||
'cartesia.name_lookup_time_ms': 'name_lookup_ms',
|
||||
'cartesia.connect_time_ms': 'connect_ms',
|
||||
'cartesia.final_response_time_ms': 'final_response_ms',
|
||||
// Rimelabs
|
||||
'rimelabs.name_lookup_time_ms': 'name_lookup_ms',
|
||||
'rimelabs.connect_time_ms': 'connect_ms',
|
||||
'rimelabs.final_response_time_ms': 'final_response_ms',
|
||||
// verbio
|
||||
'verbio.name_lookup_time_ms': 'name_lookup_ms',
|
||||
'verbio.connect_time_ms': 'connect_ms',
|
||||
'verbio.final_response_time_ms': 'final_response_ms',
|
||||
};
|
||||
|
||||
module.exports = TaskSay;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
const {TaskName, TaskPreconditions, CallStatus} = require('../utils/constants');
|
||||
|
||||
/**
|
||||
* Rejects an incoming call with user-specified status code and reason
|
||||
@@ -18,6 +18,16 @@ 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,
|
||||
sipStatus: this.data.status,
|
||||
sipReason: this.data.reason
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
135
lib/tasks/sip_refer.js
Normal file
135
lib/tasks/sip_refer.js
Normal file
@@ -0,0 +1,135 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
const {parseUri} = require('drachtio-srf');
|
||||
|
||||
/**
|
||||
* sends a sip REFER to transfer the existing call
|
||||
*/
|
||||
class TaskSipRefer extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.StableCall;
|
||||
|
||||
this.referTo = this.data.referTo;
|
||||
this.referredBy = this.data.referredBy;
|
||||
this.referredByDisplayName = this.data.referredByDisplayName;
|
||||
this.headers = this.data.headers || {};
|
||||
this.eventHook = this.data.eventHook;
|
||||
}
|
||||
|
||||
get name() { return TaskName.SipRefer; }
|
||||
|
||||
async exec(cs) {
|
||||
super.exec(cs);
|
||||
const {dlg} = cs;
|
||||
const {referTo, referredBy} = this._normalizeReferHeaders(cs, dlg);
|
||||
|
||||
try {
|
||||
this.notifyHandler = this._handleNotify.bind(this, cs, dlg);
|
||||
dlg.on('notify', this.notifyHandler);
|
||||
/* otel: trace time for tts */
|
||||
this.referSpan = this.startSpan('send-refer', {
|
||||
'refer.refer_to': referTo,
|
||||
'refer.referred_by': referredBy
|
||||
});
|
||||
|
||||
const response = await dlg.request({
|
||||
method: 'REFER',
|
||||
headers: {
|
||||
...this.headers,
|
||||
...(this.referToIsUri && {'X-Refer-To-Leave-Untouched': true}),
|
||||
'Refer-To': referTo,
|
||||
'Referred-By': referredBy
|
||||
}
|
||||
});
|
||||
this.referStatus = response.status;
|
||||
this.referSpan.setAttributes({'refer.status_code': response.status});
|
||||
this.logger.info(`TaskSipRefer:exec - received ${this.referStatus} to REFER`);
|
||||
|
||||
/* if we fail, fall through to next verb. If success, we should get BYE from far end */
|
||||
if (this.referStatus === 202) {
|
||||
this._notifyTimer = setTimeout(() => {
|
||||
this.logger.info('TaskSipRefer:exec - no NOTIFY received in 15 secs, exiting');
|
||||
this.performAction({refer_status: this.referStatus})
|
||||
.catch((err) => this.logger.error(err, 'TaskSipRefer:exec - error performing action'));
|
||||
this.notifyTaskDone();
|
||||
}, 15000);
|
||||
await this.awaitTaskDone();
|
||||
if (this._notifyTimer) {
|
||||
clearTimeout(this._notifyTimer);
|
||||
this._notifyTimer = null;
|
||||
}
|
||||
}
|
||||
else {
|
||||
await this.performAction({refer_status: this.referStatus});
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'TaskSipRefer:exec - error sending REFER');
|
||||
}
|
||||
this.referSpan?.end();
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
const {dlg} = cs;
|
||||
dlg.off('notify', this.notifyHandler);
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
async _handleNotify(cs, dlg, req, res) {
|
||||
res.send(200);
|
||||
|
||||
const contentType = req.get('Content-Type');
|
||||
this.logger.debug({body: req.body}, `TaskSipRefer:_handleNotify got ${contentType}`);
|
||||
|
||||
if (contentType?.includes('message/sipfrag')) {
|
||||
const arr = /SIP\/2\.0\s+(\d+)/.exec(req.body);
|
||||
if (arr) {
|
||||
const status = typeof arr[1] === 'string' ? parseInt(arr[1], 10) : arr[1];
|
||||
this.logger.debug(`TaskSipRefer:_handleNotify: call got status ${status}`);
|
||||
if (this.eventHook) {
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
await cs.requestor.request('verb:hook', this.eventHook,
|
||||
{event: 'transfer-status', call_status: status}, httpHeaders);
|
||||
}
|
||||
if (status >= 200) {
|
||||
this.referSpan.setAttributes({'refer.finalNotify': status});
|
||||
await this.performAction({refer_status: 202, final_referred_call_status: status})
|
||||
.catch((err) => {
|
||||
this.logger.error(err, 'TaskSipRefer:exec - error performing action finalNotify');
|
||||
});
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_normalizeReferHeaders(cs, dlg) {
|
||||
let {referTo, referredBy, referredByDisplayName} = this;
|
||||
|
||||
/* 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('"')) {
|
||||
/* they may have only provided a phone number/user */
|
||||
referTo = `sip:${referTo}@${host}`;
|
||||
}
|
||||
else this.referToIsUri = true;
|
||||
if (!referredBy) {
|
||||
/* default */
|
||||
referredBy = cs.req?.callingNumber || dlg.local.uri;
|
||||
this.logger.info({referredBy}, 'setting referredby');
|
||||
}
|
||||
if (!referredByDisplayName) {
|
||||
referredByDisplayName = cs.req?.callingName;
|
||||
}
|
||||
if (!referredBy.startsWith('<') && !referredBy.startsWith('sip') && !referredBy.startsWith('"')) {
|
||||
/* they may have only provided a phone number/user */
|
||||
referredBy = `${referredByDisplayName ? `"${referredByDisplayName}"` : ''}<sip:${referredBy}@${host}>`;
|
||||
}
|
||||
return {referTo, referredBy};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskSipRefer;
|
||||
49
lib/tasks/sip_request.js
Normal file
49
lib/tasks/sip_request.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
|
||||
/**
|
||||
* Send a SIP request (e.g. INFO, NOTIFY, etc) on an existing call leg
|
||||
*/
|
||||
class TaskSipRequest extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.StableCall;
|
||||
|
||||
this.method = this.data.method.toUpperCase();
|
||||
this.headers = this.data.headers || {};
|
||||
this.body = this.data.body;
|
||||
if (this.body) this.body = `${this.body}\n`;
|
||||
}
|
||||
|
||||
get name() { return TaskName.SipRequest; }
|
||||
|
||||
async exec(cs, {dlg}) {
|
||||
super.exec(cs);
|
||||
try {
|
||||
this.logger.info({dlg}, `TaskSipRequest: sending a SIP ${this.method}`);
|
||||
const res = await dlg.request({
|
||||
method: this.method,
|
||||
headers: this.headers,
|
||||
body: this.body
|
||||
});
|
||||
const result = {result: 'success', sipStatus: res.status};
|
||||
this.span.setAttributes({
|
||||
...this.headers,
|
||||
...(this.body && {body: this.body}),
|
||||
'response.status_code': res.status
|
||||
});
|
||||
this.logger.debug({result}, `TaskSipRequest: received response to ${this.method}`);
|
||||
await this.performAction(result);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'TaskSipRequest: error');
|
||||
this.span.setAttributes({
|
||||
...this.headers,
|
||||
...(this.body && {body: this.body}),
|
||||
'response.error': err.message
|
||||
});
|
||||
await this.performAction({result: 'failed', err: err.message});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskSipRequest;
|
||||
@@ -1,214 +0,0 @@
|
||||
{
|
||||
"sip:decline": {
|
||||
"properties": {
|
||||
"status": "number",
|
||||
"reason": "string",
|
||||
"headers": "object"
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"hangup": {
|
||||
"properties": {
|
||||
"headers": "object"
|
||||
},
|
||||
"required": [
|
||||
]
|
||||
},
|
||||
"play": {
|
||||
"properties": {
|
||||
"url": "string",
|
||||
"loop": "number",
|
||||
"earlyMedia": "boolean"
|
||||
},
|
||||
"required": [
|
||||
"url"
|
||||
]
|
||||
},
|
||||
"say": {
|
||||
"properties": {
|
||||
"text": "string",
|
||||
"loop": "number",
|
||||
"synthesizer": "#synthesizer",
|
||||
"earlyMedia": "boolean"
|
||||
},
|
||||
"required": [
|
||||
"text"
|
||||
]
|
||||
},
|
||||
"gather": {
|
||||
"properties": {
|
||||
"actionHook": "object|string",
|
||||
"finishOnKey": "string",
|
||||
"input": "array",
|
||||
"numDigits": "number",
|
||||
"partialResultHook": "object|string",
|
||||
"speechTimeout": "number",
|
||||
"timeout": "number",
|
||||
"recognizer": "#recognizer",
|
||||
"play": "#play",
|
||||
"say": "#say"
|
||||
},
|
||||
"required": [
|
||||
"actionHook"
|
||||
]
|
||||
},
|
||||
"dial": {
|
||||
"properties": {
|
||||
"actionHook": "object|string",
|
||||
"answerOnBridge": "boolean",
|
||||
"callerId": "string",
|
||||
"confirmHook": "object|string",
|
||||
"dialMusic": "string",
|
||||
"dtmfCapture": "object",
|
||||
"dtmfHook": "object|string",
|
||||
"headers": "object",
|
||||
"listen": "#listen",
|
||||
"target": ["#target"],
|
||||
"timeLimit": "number",
|
||||
"timeout": "number",
|
||||
"transcribe": "#transcribe"
|
||||
},
|
||||
"required": [
|
||||
"target"
|
||||
]
|
||||
},
|
||||
"listen": {
|
||||
"properties": {
|
||||
"actionHook": "object|string",
|
||||
"auth": "#auth",
|
||||
"finishOnKey": "string",
|
||||
"maxLength": "number",
|
||||
"metadata": "object",
|
||||
"mixType": {
|
||||
"type": "string",
|
||||
"enum": ["mono", "stereo", "mixed"]
|
||||
},
|
||||
"passDtmf": "boolean",
|
||||
"playBeep": "boolean",
|
||||
"sampleRate": "number",
|
||||
"timeout": "number",
|
||||
"transcribe": "#transcribe",
|
||||
"url": "string",
|
||||
"wsAuth": "#auth",
|
||||
"earlyMedia": "boolean"
|
||||
},
|
||||
"required": [
|
||||
"url"
|
||||
]
|
||||
},
|
||||
"pause": {
|
||||
"properties": {
|
||||
"length": "number"
|
||||
},
|
||||
"required": [
|
||||
"length"
|
||||
]
|
||||
},
|
||||
"redirect": {
|
||||
"properties": {
|
||||
"actionHook": "object|string"
|
||||
},
|
||||
"required": [
|
||||
"actionHook"
|
||||
]
|
||||
},
|
||||
"rest:dial": {
|
||||
"properties": {
|
||||
"account_sid": "string",
|
||||
"application_sid": "string",
|
||||
"call_hook": "object|string",
|
||||
"call_status_hook": "object|string",
|
||||
"from": "string",
|
||||
"speech_synthesis_vendor": "string",
|
||||
"speech_synthesis_voice": "string",
|
||||
"speech_recognizer_vendor": "string",
|
||||
"speech_recognizer_language": "string",
|
||||
"tag": "object",
|
||||
"to": "#target",
|
||||
"timeout": "number"
|
||||
},
|
||||
"required": [
|
||||
"call_hook",
|
||||
"from",
|
||||
"to"
|
||||
]
|
||||
},
|
||||
"tag": {
|
||||
"properties": {
|
||||
"data": "object"
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"transcribe": {
|
||||
"properties": {
|
||||
"transcriptionHook": "string",
|
||||
"recognizer": "#recognizer",
|
||||
"earlyMedia": "boolean"
|
||||
},
|
||||
"required": [
|
||||
"transcriptionHook"
|
||||
]
|
||||
},
|
||||
"target": {
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["phone", "sip", "user"]
|
||||
},
|
||||
"url": "string",
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["GET", "POST"]
|
||||
},
|
||||
"number": "string",
|
||||
"sipUri": "string",
|
||||
"auth": "#auth",
|
||||
"name": "string"
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"auth": {
|
||||
"properties": {
|
||||
"username": "string",
|
||||
"password": "string"
|
||||
},
|
||||
"required": [
|
||||
"username",
|
||||
"password"
|
||||
]
|
||||
},
|
||||
"synthesizer": {
|
||||
"properties": {
|
||||
"vendor": {
|
||||
"type": "string",
|
||||
"enum": ["google"]
|
||||
},
|
||||
"voice": "string"
|
||||
},
|
||||
"required": [
|
||||
"vendor"
|
||||
]
|
||||
},
|
||||
"recognizer": {
|
||||
"properties": {
|
||||
"vendor": {
|
||||
"type": "string",
|
||||
"enum": ["google"]
|
||||
},
|
||||
"language": "string",
|
||||
"hints": "array",
|
||||
"profanityFilter": "boolean",
|
||||
"interim": "boolean",
|
||||
"dualChannel": "boolean"
|
||||
},
|
||||
"required": [
|
||||
"vendor"
|
||||
]
|
||||
}
|
||||
}
|
||||
434
lib/tasks/stt-task.js
Normal file
434
lib/tasks/stt-task.js
Normal file
@@ -0,0 +1,434 @@
|
||||
const Task = require('./task');
|
||||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
const { TaskPreconditions, CobaltTranscriptionEvents } = require('../utils/constants');
|
||||
const { SpeechCredentialError } = require('../utils/error');
|
||||
const {JAMBONES_AWS_TRANSCRIBE_USE_GRPC} = require('../config');
|
||||
|
||||
/**
|
||||
* "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 {
|
||||
|
||||
constructor(logger, data, parentTask) {
|
||||
super(logger, data);
|
||||
this.parentTask = parentTask;
|
||||
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
const {
|
||||
setChannelVarsForStt,
|
||||
normalizeTranscription,
|
||||
setSpeechCredentialsAtRuntime,
|
||||
compileSonioxTranscripts,
|
||||
consolidateTranscripts,
|
||||
updateSpeechmaticsPayload
|
||||
} = require('../utils/transcription-utils')(logger);
|
||||
this.setChannelVarsForStt = setChannelVarsForStt;
|
||||
this.normalizeTranscription = normalizeTranscription;
|
||||
this.compileSonioxTranscripts = compileSonioxTranscripts;
|
||||
this.consolidateTranscripts = consolidateTranscripts;
|
||||
this.updateSpeechmaticsPayload = updateSpeechmaticsPayload;
|
||||
this.eventHandlers = [];
|
||||
this.isHandledByPrimaryProvider = true;
|
||||
/**
|
||||
* Task use taskIncludeRecognizer to identify
|
||||
* if taskIncludeRecognizer === true, use label from verb.recognizer, even it's empty
|
||||
* if taskIncludeRecognizer === false, use label from application.recognizer
|
||||
*/
|
||||
this.taskIncludeRecognizer = !!this.data.recognizer;
|
||||
if (this.data.recognizer) {
|
||||
const recognizer = this.data.recognizer;
|
||||
this.vendor = recognizer.vendor;
|
||||
this.language = recognizer.language;
|
||||
this.label = recognizer.label;
|
||||
|
||||
//fallback
|
||||
this.fallbackVendor = recognizer.fallbackVendor || 'default';
|
||||
this.fallbackLanguage = recognizer.fallbackLanguage || 'default';
|
||||
this.fallbackLabel = recognizer.fallbackLabel;
|
||||
|
||||
/* let credentials be supplied in the recognizer object at runtime */
|
||||
this.sttCredentials = setSpeechCredentialsAtRuntime(recognizer);
|
||||
|
||||
if (!Array.isArray(this.data.recognizer.altLanguages)) {
|
||||
this.data.recognizer.altLanguages = [];
|
||||
}
|
||||
} else {
|
||||
this.data.recognizer = {hints: [], altLanguages: []};
|
||||
}
|
||||
|
||||
/* buffer for soniox transcripts */
|
||||
this._sonioxTranscripts = [];
|
||||
/*bug name prefix */
|
||||
this.bugname_prefix = '';
|
||||
|
||||
}
|
||||
|
||||
async exec(cs, {ep, ep2}) {
|
||||
super.exec(cs);
|
||||
this.ep = ep;
|
||||
this.ep2 = ep2;
|
||||
|
||||
// use session preferences if we don't have specific verb-level settings.
|
||||
if (cs.recognizer) {
|
||||
for (const k in cs.recognizer) {
|
||||
const newValue = this.data.recognizer && this.data.recognizer[k] !== undefined ?
|
||||
this.data.recognizer[k] :
|
||||
cs.recognizer[k];
|
||||
|
||||
if (Array.isArray(newValue)) {
|
||||
this.data.recognizer[k] = [...(this.data.recognizer[k] || []), ...cs.recognizer[k]];
|
||||
} else if (typeof newValue === 'object' && newValue !== null) {
|
||||
this.data.recognizer[k] = { ...(this.data.recognizer[k] || {}), ...cs.recognizer[k] };
|
||||
} else {
|
||||
this.data.recognizer[k] = newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ('default' === this.vendor || !this.vendor) {
|
||||
this.vendor = cs.speechRecognizerVendor;
|
||||
if (this.data.recognizer) this.data.recognizer.vendor = this.vendor;
|
||||
}
|
||||
if ('default' === this.language || !this.language) {
|
||||
this.language = cs.speechRecognizerLanguage;
|
||||
if (this.data.recognizer) this.data.recognizer.language = this.language;
|
||||
}
|
||||
if (!this.taskIncludeRecognizer) {
|
||||
this.label = cs.speechRecognizerLabel;
|
||||
if (this.data.recognizer) this.data.recognizer.label = this.label;
|
||||
}
|
||||
// Fallback options
|
||||
if ('default' === this.fallbackVendor || !this.fallbackVendor) {
|
||||
this.fallbackVendor = cs.fallbackSpeechRecognizerVendor;
|
||||
if (this.data.recognizer) this.data.recognizer.fallbackVendor = this.fallbackVendor;
|
||||
}
|
||||
if ('default' === this.fallbackLanguage || !this.fallbackLanguage) {
|
||||
this.fallbackLanguage = cs.fallbackSpeechRecognizerLanguage;
|
||||
if (this.data.recognizer) this.data.recognizer.fallbackLanguage = this.fallbackLanguage;
|
||||
}
|
||||
if (!this.taskIncludeRecognizer) {
|
||||
this.fallbackLabel = cs.fallbackSpeechRecognizerLabel;
|
||||
if (this.data.recognizer) this.data.recognizer.fallbackLabel = this.fallbackLabel;
|
||||
}
|
||||
|
||||
if (cs.hasFallbackAsr) {
|
||||
if (this.taskIncludeRecognizer) {
|
||||
// reset fallback ASR from previous run if this verb contains data.recognizer.
|
||||
cs.hasFallbackAsr = false;
|
||||
} else {
|
||||
this.logger.debug('Call session has fallback to 2nd ASR, use 2nd recognizer configuration');
|
||||
this.vendor = this.fallbackVendor;
|
||||
this.language = this.fallbackLanguage;
|
||||
this.label = this.fallbackLabel;
|
||||
}
|
||||
}
|
||||
if (!this.data.recognizer.vendor) {
|
||||
this.data.recognizer.vendor = this.vendor;
|
||||
}
|
||||
if (this.vendor === 'cobalt' && !this.data.recognizer.model) {
|
||||
// By default, application saves cobalt model in language
|
||||
this.data.recognizer.model = cs.speechRecognizerLanguage;
|
||||
}
|
||||
|
||||
if (
|
||||
// not gather task, such as transcribe
|
||||
(!this.input ||
|
||||
// gather task with speech
|
||||
this.input.includes('speech')) &&
|
||||
!this.sttCredentials) {
|
||||
try {
|
||||
this.sttCredentials = await this._initSpeechCredentials(this.cs, this.vendor, this.label);
|
||||
} catch (error) {
|
||||
if (this.canFallback) {
|
||||
this.notifyError(
|
||||
{
|
||||
msg: 'ASR error', details:`Invalid vendor ${this.vendor}, Error: ${error}`,
|
||||
failover: 'in progress'
|
||||
});
|
||||
await this._initFallback();
|
||||
} else {
|
||||
this.notifyError(
|
||||
{
|
||||
msg: 'ASR error', details:`Invalid vendor ${this.vendor}, Error: ${error}`,
|
||||
failover: 'not available'
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* when using cobalt model is required */
|
||||
if (this.vendor === 'cobalt' && !this.data.recognizer.model) {
|
||||
this.notifyError({ msg: 'ASR error', details:'Cobalt requires a model to be specified'});
|
||||
throw new Error('Cobalt requires a model to be specified');
|
||||
}
|
||||
|
||||
if (cs.hasAltLanguages) {
|
||||
this.data.recognizer.altLanguages = this.data.recognizer.altLanguages.concat(cs.altLanguages);
|
||||
this.logger.debug({altLanguages: this.altLanguages},
|
||||
'STT:exec - applying altLanguages');
|
||||
}
|
||||
if (cs.hasGlobalSttPunctuation && !this.data.recognizer.punctuation) {
|
||||
this.data.recognizer.punctuation = cs.globalSttPunctuation;
|
||||
}
|
||||
}
|
||||
|
||||
addCustomEventListener(ep, event, handler) {
|
||||
this.eventHandlers.push({ep, event, handler});
|
||||
ep.addCustomEventListener(event, handler);
|
||||
}
|
||||
|
||||
removeCustomEventListeners() {
|
||||
this.eventHandlers.forEach((h) => h.ep.removeCustomEventListener(h.event, h.handler));
|
||||
}
|
||||
|
||||
async _initSpeechCredentials(cs, vendor, label) {
|
||||
const {getNuanceAccessToken, getIbmAccessToken, getAwsAuthToken, getVerbioAccessToken} = cs.srf.locals.dbHelpers;
|
||||
let credentials = cs.getSpeechCredentials(vendor, 'stt', label);
|
||||
|
||||
if (!credentials) {
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
this.logger.info(`ERROR stt using ${vendor} requested but creds not supplied`);
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.STT_NOT_PROVISIONED,
|
||||
vendor,
|
||||
target_sid: cs.callSid
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for no stt'));
|
||||
// the ASR might have fallback configuration, should not done task here.
|
||||
throw new SpeechCredentialError(`No speech-to-text service credentials for ${vendor} have been configured`);
|
||||
}
|
||||
|
||||
if (vendor === 'nuance' && credentials.client_id) {
|
||||
/* get nuance access token */
|
||||
const {client_id, secret} = credentials;
|
||||
const {access_token, servedFromCache} = await getNuanceAccessToken(client_id, secret, 'asr tts');
|
||||
this.logger.debug({client_id}, `got nuance access token ${servedFromCache ? 'from cache' : ''}`);
|
||||
credentials = {...credentials, access_token};
|
||||
}
|
||||
else if (vendor == 'ibm' && credentials.stt_api_key) {
|
||||
/* get ibm access token */
|
||||
const {stt_api_key, stt_region} = credentials;
|
||||
const {access_token, servedFromCache} = await getIbmAccessToken(stt_api_key);
|
||||
this.logger.debug({stt_api_key}, `got ibm access token ${servedFromCache ? 'from cache' : ''}`);
|
||||
credentials = {...credentials, access_token, stt_region};
|
||||
} else if (['aws', 'polly'].includes(vendor) && credentials.roleArn) {
|
||||
/* get aws access token */
|
||||
const {roleArn, region} = credentials;
|
||||
const {accessKeyId, secretAccessKey, sessionToken, servedFromCache} =
|
||||
await getAwsAuthToken({
|
||||
region,
|
||||
roleArn
|
||||
});
|
||||
this.logger.debug({roleArn}, `(roleArn) got aws access token ${servedFromCache ? 'from cache' : ''}`);
|
||||
// from role ARN, we will get SessionToken, but feature server use it as securityToken.
|
||||
credentials = {...credentials, accessKeyId, secretAccessKey, securityToken: sessionToken};
|
||||
}
|
||||
else if (vendor === 'verbio' && credentials.client_id && credentials.client_secret) {
|
||||
const {access_token, servedFromCache} = await getVerbioAccessToken(credentials);
|
||||
this.logger.debug({client_id: credentials.client_id},
|
||||
`got verbio access token ${servedFromCache ? 'from cache' : ''}`);
|
||||
credentials.access_token = access_token;
|
||||
}
|
||||
else if (vendor == 'aws' && !JAMBONES_AWS_TRANSCRIBE_USE_GRPC) {
|
||||
/* get AWS access token */
|
||||
const {speech_credential_sid, accessKeyId, secretAccessKey, securityToken, region } = credentials;
|
||||
if (!securityToken) {
|
||||
const { servedFromCache, ...newCredentials} = await getAwsAuthToken({
|
||||
speech_credential_sid,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
region});
|
||||
this.logger.debug({newCredentials}, `got aws security token ${servedFromCache ? 'from cache' : ''}`);
|
||||
credentials = {...newCredentials, region};
|
||||
}
|
||||
}
|
||||
|
||||
return credentials;
|
||||
}
|
||||
|
||||
get canFallback() {
|
||||
return this.fallbackVendor && this.isHandledByPrimaryProvider && !this.cs.hasFallbackAsr;
|
||||
}
|
||||
|
||||
async _initFallback() {
|
||||
assert(this.fallbackVendor, 'fallback failed without fallbackVendor configuration');
|
||||
this.logger.info(`Failed to use primary STT provider, fallback to ${this.fallbackVendor}`);
|
||||
this.isHandledByPrimaryProvider = false;
|
||||
this.cs.hasFallbackAsr = true;
|
||||
this.vendor = this.cs.fallbackSpeechRecognizerVendor = this.fallbackVendor;
|
||||
this.language = this.cs.fallbackSpeechRecognizerLanguage = this.fallbackLanguage;
|
||||
this.label = this.cs.fallbackSpeechRecognizerLabel = this.fallbackLabel;
|
||||
this.data.recognizer.vendor = this.vendor;
|
||||
this.data.recognizer.language = this.language;
|
||||
this.data.recognizer.label = this.label;
|
||||
this.sttCredentials = await this._initSpeechCredentials(this.cs, this.vendor, this.label);
|
||||
// cleanup previous listener from previous vendor
|
||||
this.removeCustomEventListeners();
|
||||
}
|
||||
|
||||
async compileHintsForCobalt(ep, hostport, model, token, hints) {
|
||||
const {retrieveKey} = this.cs.srf.locals.dbHelpers;
|
||||
const hash = crypto.createHash('sha1');
|
||||
hash.update(`${model}:${hints}`);
|
||||
const key = `cobalt:${hash.digest('hex')}`;
|
||||
this.context = await retrieveKey(key);
|
||||
if (this.context) {
|
||||
this.logger.debug({model, hints}, 'found cached cobalt context for supplied hints');
|
||||
return this.context;
|
||||
}
|
||||
|
||||
this.logger.debug({model, hints}, 'compiling cobalt context for supplied hints');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.cobaltCompileResolver = resolve;
|
||||
ep.addCustomEventListener(CobaltTranscriptionEvents.CompileContext, this._onCompileContext.bind(this, ep, key));
|
||||
ep.api('uuid_cobalt_compile_context', [ep.uuid, hostport, model, token, hints], (err, evt) => {
|
||||
if (err || 0 !== evt.getBody().indexOf('+OK')) {
|
||||
ep.removeCustomEventListener(CobaltTranscriptionEvents.CompileContext);
|
||||
return reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
this.cobaltCompileResolver(evt.compiled_context);
|
||||
ep.removeCustomEventListener(CobaltTranscriptionEvents.CompileContext);
|
||||
this.cobaltCompileResolver = null;
|
||||
|
||||
//cache the compiled context
|
||||
addKey(key, evt.compiled_context, 3600 * 12)
|
||||
.catch((err) => this.logger.info({err}, `Error caching cobalt context for ${key}`));
|
||||
}
|
||||
|
||||
_doContinuousAsrWithDeepgram(asrTimeout) {
|
||||
/* deepgram has an utterance_end_ms property that simplifies things */
|
||||
assert(this.vendor === 'deepgram');
|
||||
if (asrTimeout < 1000) {
|
||||
this.notifyError({
|
||||
msg: 'ASR error',
|
||||
details:`asrTimeout ${asrTimeout} is too short for deepgram; setting it to 1000ms`
|
||||
});
|
||||
asrTimeout = 1000;
|
||||
}
|
||||
else if (asrTimeout > 5000) {
|
||||
this.notifyError({
|
||||
msg: 'ASR error',
|
||||
details:`asrTimeout ${asrTimeout} is too long for deepgram; setting it to 5000ms`
|
||||
});
|
||||
asrTimeout = 5000;
|
||||
}
|
||||
this.logger.debug(`_doContinuousAsrWithDeepgram - setting utterance_end_ms to ${asrTimeout}`);
|
||||
const dgOptions = this.data.recognizer.deepgramOptions = this.data.recognizer.deepgramOptions || {};
|
||||
dgOptions.utteranceEndMs = dgOptions.utteranceEndMs || asrTimeout;
|
||||
}
|
||||
|
||||
_onVendorConnect(_cs, _ep) {
|
||||
this.logger.debug(`TaskGather:_on${this.vendor}Connect`);
|
||||
}
|
||||
|
||||
_onVendorError(cs, _ep, evt) {
|
||||
this.logger.info({evt}, `${this.name}:_on${this.vendor}Error`);
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.STT_FAILURE,
|
||||
message: 'STT failure reported by vendor',
|
||||
detail: evt.error,
|
||||
vendor: this.vendor,
|
||||
target_sid: cs.callSid
|
||||
}).catch((err) => this.logger.info({err}, `Error generating alert for ${this.vendor} connection failure`));
|
||||
}
|
||||
|
||||
_onVendorConnectFailure(cs, _ep, evt) {
|
||||
const {reason} = evt;
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
this.logger.info({evt}, `${this.name}:_on${this.vendor}ConnectFailure`);
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.STT_FAILURE,
|
||||
message: `Failed connecting to ${this.vendor} speech recognizer: ${reason}`,
|
||||
vendor: this.vendor,
|
||||
target_sid: cs.callSid
|
||||
}).catch((err) => this.logger.info({err}, `Error generating alert for ${this.vendor} connection failure`));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SttTask;
|
||||
@@ -12,7 +12,7 @@ class TaskTag extends Task {
|
||||
async exec(cs) {
|
||||
super.exec(cs);
|
||||
cs.callInfo.customerData = this.data;
|
||||
//this.logger.debug({callInfo: cs.callInfo.toJSON()}, 'TaskTag:exec set customer data in callInfo');
|
||||
this.logger.debug({customerData: cs.callInfo.customerData}, 'TaskTag:exec set customer data in callInfo');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
const Emitter = require('events');
|
||||
const debug = require('debug')('jambonz:feature-server');
|
||||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
const {TaskPreconditions} = require('../utils/constants');
|
||||
const normalizeJamones = require('../utils/normalize-jamones');
|
||||
const specs = new Map();
|
||||
const _specData = require('./specs');
|
||||
for (const key in _specData) {specs.set(key, _specData[key]);}
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const WsRequestor = require('../utils/ws-requestor');
|
||||
const {TaskName} = require('../utils/constants');
|
||||
const {trace} = require('@opentelemetry/api');
|
||||
|
||||
/**
|
||||
* @classdesc Represents a jambonz verb. This is a superclass that is extended
|
||||
@@ -19,9 +18,14 @@ class Task extends Emitter {
|
||||
this.logger = logger;
|
||||
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);
|
||||
|
||||
/* used when we play a prompt to a member in conference */
|
||||
this._confPlayCompletionPromise = new Promise((resolve) => this._confPlayCompletionResolver = resolve);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,6 +42,14 @@ class Task extends Emitter {
|
||||
return this.cs;
|
||||
}
|
||||
|
||||
get summary() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
set disableTracing(val) {
|
||||
this._disableTracing = val;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.data;
|
||||
}
|
||||
@@ -55,10 +67,40 @@ class Task extends Emitter {
|
||||
* called to kill (/stop) a running task
|
||||
* what to do is up to each type of task
|
||||
*/
|
||||
kill() {
|
||||
this.logger.debug(`${this.name} is being killed`);
|
||||
kill(cs) {
|
||||
if (this.cs && !this.cs.isConfirmCallSession) this.logger.debug(`${this.name} is being killed`);
|
||||
this._killInProgress = true;
|
||||
// no-op
|
||||
|
||||
/* remove reference to parent task or else entangled parent-child tasks will not be gc'ed */
|
||||
setImmediate(() => this.parentTask = null);
|
||||
}
|
||||
|
||||
startSpan(name, attributes) {
|
||||
const {srf} = require('../..');
|
||||
const {tracer} = srf.locals.otel;
|
||||
const span = tracer.startSpan(name, undefined, this.ctx);
|
||||
if (attributes) span.setAttributes(attributes);
|
||||
trace.setSpan(this.ctx, span);
|
||||
return span;
|
||||
}
|
||||
|
||||
startChildSpan(name, attributes) {
|
||||
const {srf} = require('../..');
|
||||
const {tracer} = srf.locals.otel;
|
||||
const span = tracer.startSpan(name, undefined, this.ctx);
|
||||
if (attributes) span.setAttributes(attributes);
|
||||
const ctx = trace.setSpan(this.ctx, span);
|
||||
return {span, ctx};
|
||||
}
|
||||
|
||||
getTracingPropagation(encoding, span) {
|
||||
// TODO: support encodings beyond b3 https://github.com/openzipkin/b3-propagation
|
||||
if (span) {
|
||||
return `${span.spanContext().traceId}-${span.spanContext().spanId}-1`;
|
||||
}
|
||||
if (this.span) {
|
||||
return `${this.span.spanContext().traceId}-${this.span.spanContext().spanId}-1`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,6 +118,21 @@ class Task extends Emitter {
|
||||
return this._completionPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* when a play to conference member completes
|
||||
*/
|
||||
notifyConfPlayDone() {
|
||||
this._confPlayCompletionResolver();
|
||||
}
|
||||
|
||||
/**
|
||||
* when a subclass task has launched various async activities and is now simply waiting
|
||||
* for them to complete it should call this method to block until that happens
|
||||
*/
|
||||
awaitConfPlayDone() {
|
||||
return this._confPlayCompletionPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* provided as a convenience for tasks, this simply calls CallSession#normalizeUrl
|
||||
*/
|
||||
@@ -83,86 +140,172 @@ class Task extends Emitter {
|
||||
return this.callSession.normalizeUrl(url, method, auth);
|
||||
}
|
||||
|
||||
notifyError(obj) {
|
||||
if (this.cs.requestor instanceof WsRequestor) {
|
||||
const params = {...obj, verb: this.name, id: this.id};
|
||||
this.cs.requestor.request('jambonz:error', '/error', params)
|
||||
.catch((err) => this.logger.info({err}, 'Task:notifyError error sending error'));
|
||||
}
|
||||
}
|
||||
|
||||
notifyStatus(obj) {
|
||||
if (this.cs.notifyEvents && this.cs.requestor instanceof WsRequestor) {
|
||||
const params = {...obj, verb: this.name, id: this.id};
|
||||
this.cs.requestor.request('verb:status', '/status', params)
|
||||
.catch((err) => this.logger.info({err}, 'Task:notifyStatus error sending error'));
|
||||
}
|
||||
}
|
||||
|
||||
async performAction(results, expectResponse = true) {
|
||||
if (this.actionHook) {
|
||||
const params = results ? Object.assign(results, this.cs.callInfo.toJSON()) : this.cs.callInfo.toJSON();
|
||||
const json = await this.cs.requestor.request(this.actionHook, params);
|
||||
if (expectResponse && json && Array.isArray(json)) {
|
||||
const makeTask = require('./make_task');
|
||||
const tasks = normalizeJamones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
if (tasks && tasks.length > 0) {
|
||||
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
|
||||
this.callSession.replaceApplication(tasks);
|
||||
const type = this.name === TaskName.Redirect ? 'session:redirect' : 'verb:hook';
|
||||
const params = results ? Object.assign(this.cs.callInfo.toJSON(), results) : this.cs.callInfo.toJSON();
|
||||
const span = this.startSpan(`${type} (${this.actionHook})`);
|
||||
const b3 = this.getTracingPropagation('b3', span);
|
||||
const httpHeaders = b3 && {b3};
|
||||
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, span);
|
||||
span.setAttributes({'http.statusCode': 200});
|
||||
const isWsConnection = this.cs.requestor instanceof WsRequestor;
|
||||
if (!isWsConnection || (expectResponse && json && Array.isArray(json) && json.length)) {
|
||||
span.end();
|
||||
} else {
|
||||
/** we use this span to measure application response latency,
|
||||
* and with websocket connections we generally get the application's response
|
||||
* in a subsequent message from the far end, so we terminate the span when the
|
||||
* first new set of verbs arrive after sending a transcript
|
||||
* */
|
||||
this.emit('VerbHookSpanWaitForEnd', {span});
|
||||
|
||||
// If actionHook delay action is configured, and ws application have not responded yet any verb for actionHook
|
||||
// We have to transfer the task to call-session to await on next ws command verbs, and also run action Hook
|
||||
// delay actions
|
||||
//if (this.hookDelayActionOpts) {
|
||||
// this.emit('ActionHookDelayActionOptions', this.hookDelayActionOpts);
|
||||
//}
|
||||
}
|
||||
if (expectResponse && json && Array.isArray(json)) {
|
||||
const makeTask = require('./make_task');
|
||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
if (tasks && tasks.length > 0) {
|
||||
this.callSession.replaceApplication(tasks);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
span.setAttributes({'http.statusCode': err.statusCode});
|
||||
span.end();
|
||||
throw err;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async performHook(cs, hook, results) {
|
||||
const params = results ? Object.assign(cs.callInfo.toJSON(), results) : cs.callInfo.toJSON();
|
||||
const span = this.startSpan('verb:hook', {'hook.url': hook});
|
||||
const b3 = this.getTracingPropagation('b3', span);
|
||||
const httpHeaders = b3 && {b3};
|
||||
span.setAttributes({'http.body': JSON.stringify(params)});
|
||||
try {
|
||||
const json = await cs.requestor.request('verb:hook', hook, params, httpHeaders, span);
|
||||
span.setAttributes({'http.statusCode': 200});
|
||||
span.end();
|
||||
if (json && Array.isArray(json)) {
|
||||
const makeTask = require('./make_task');
|
||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
if (tasks && tasks.length > 0) {
|
||||
this.redirect(cs, tasks);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
span.setAttributes({'http.statusCode': err.statusCode});
|
||||
span.end();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
redirect(cs, tasks) {
|
||||
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
|
||||
this.isReplacingApplication = true;
|
||||
cs.replaceApplication(tasks);
|
||||
}
|
||||
|
||||
async playToConfMember(ep, memberId, confName, confUuid, filepath) {
|
||||
try {
|
||||
this.logger.debug(`Task:playToConfMember - playing ${filepath} to ${confName}:${memberId}`);
|
||||
|
||||
// listen for conference events
|
||||
const handler = this.__onConferenceEvent.bind(this);
|
||||
ep.conn.on('esl::event::CUSTOM::*', handler) ;
|
||||
const response = await ep.api(`conference ${confName} play ${filepath} ${memberId}`);
|
||||
this.logger.debug({response}, 'Task:playToConfMember - api call returned');
|
||||
await this.awaitConfPlayDone();
|
||||
ep.conn.removeListener('esl::event::CUSTOM::*', handler);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, `Task:playToConfMember - error playing ${filepath} to ${confName}:${memberId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async killPlayToConfMember(ep, memberId, confName) {
|
||||
try {
|
||||
this.logger.debug(`Task:killPlayToConfMember - killing audio to ${confName}:${memberId}`);
|
||||
const response = await ep.api(`conference ${confName} stop ${memberId}`);
|
||||
this.logger.debug({response}, 'Task:killPlayToConfMember - api call returned');
|
||||
} catch (err) {
|
||||
this.logger.error({err}, `Task:killPlayToConfMember - error killing audio to ${confName}:${memberId}`);
|
||||
}
|
||||
}
|
||||
|
||||
__onConferenceEvent(evt) {
|
||||
const eventName = evt.getHeader('Event-Subclass') ;
|
||||
if (eventName === 'conference::maintenance') {
|
||||
const action = evt.getHeader('Action') ;
|
||||
if (action === 'play-file-member-done') {
|
||||
this.logger.debug('done playing file to conf member');
|
||||
this.notifyConfPlayDone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* validate that the JSON task description is valid
|
||||
* @param {string} name - verb name
|
||||
* @param {object} data - verb properties
|
||||
*/
|
||||
static validate(name, data) {
|
||||
debug(`validating ${name} with data ${JSON.stringify(data)}`);
|
||||
// validate the instruction is supported
|
||||
if (!specs.has(name)) throw new Error(`invalid instruction: ${name}`);
|
||||
|
||||
// check type of each element and make sure required elements are present
|
||||
const specData = specs.get(name);
|
||||
let required = specData.required || [];
|
||||
for (const dKey in data) {
|
||||
if (dKey in specData.properties) {
|
||||
const dVal = data[dKey];
|
||||
const dSpec = specData.properties[dKey];
|
||||
debug(`Task:validate validating property ${dKey} with value ${JSON.stringify(dVal)}`);
|
||||
|
||||
if (typeof dSpec === 'string' && ['number', 'string', 'object', 'boolean'].includes(dSpec)) {
|
||||
// simple types
|
||||
if (typeof dVal !== specData.properties[dKey]) {
|
||||
throw new Error(`${name}: property ${dKey} has invalid data type`);
|
||||
}
|
||||
}
|
||||
else if (typeof dSpec === 'string' && dSpec === 'array') {
|
||||
if (!Array.isArray(dVal)) throw new Error(`${name}: property ${dKey} is not an array`);
|
||||
}
|
||||
else if (typeof dSpec === 'string' && dSpec.includes('|')) {
|
||||
const types = dSpec.split('|').map((t) => t.trim());
|
||||
if (!types.includes(typeof dVal)) {
|
||||
throw new Error(`${name}: property ${dKey} has invalid data type, must be one of ${types}`);
|
||||
}
|
||||
}
|
||||
else if (Array.isArray(dSpec) && dSpec[0].startsWith('#')) {
|
||||
const name = dSpec[0].slice(1);
|
||||
for (const item of dVal) {
|
||||
Task.validate(name, item);
|
||||
}
|
||||
}
|
||||
else if (typeof dSpec === 'object') {
|
||||
// complex types
|
||||
const type = dSpec.type;
|
||||
assert.ok(['number', 'string', 'object', 'boolean'].includes(type),
|
||||
`invalid or missing type in spec ${JSON.stringify(dSpec)}`);
|
||||
if (type === 'string' && dSpec.enum) {
|
||||
assert.ok(Array.isArray(dSpec.enum), `enum must be an array ${JSON.stringify(dSpec.enum)}`);
|
||||
if (!dSpec.enum.includes(dVal)) throw new Error(`invalid value ${dVal} must be one of ${dSpec.enum}`);
|
||||
}
|
||||
}
|
||||
else if (typeof dSpec === 'string' && dSpec.startsWith('#')) {
|
||||
// reference to another datatype (i.e. nested type)
|
||||
const name = dSpec.slice(1);
|
||||
//const obj = {};
|
||||
//obj[name] = dVal;
|
||||
Task.validate(name, dVal);
|
||||
}
|
||||
else {
|
||||
assert.ok(0, `invalid spec ${JSON.stringify(dSpec)}`);
|
||||
}
|
||||
required = required.filter((item) => item !== dKey);
|
||||
}
|
||||
else throw new Error(`${name}: unknown property ${dKey}`);
|
||||
async transferCallToFeatureServer(cs, sipAddress, opts) {
|
||||
const uuid = crypto.randomUUID();
|
||||
const {addKey} = cs.srf.locals.dbHelpers;
|
||||
const obj = Object.assign({}, cs.application);
|
||||
delete obj.requestor;
|
||||
delete obj.notifier;
|
||||
obj.tasks = cs.getRemainingTaskData();
|
||||
obj.callInfo = cs.callInfo.toJSON();
|
||||
if (opts && obj.tasks.length > 0) {
|
||||
const key = Object.keys(obj.tasks[0])[0];
|
||||
Object.assign(obj.tasks[0][key], {_: opts});
|
||||
}
|
||||
|
||||
this.logger.debug({obj}, 'Task:_doRefer - final object to store for receiving session on othe server');
|
||||
|
||||
const success = await addKey(uuid, JSON.stringify(obj), 30);
|
||||
if (!success) {
|
||||
this.logger.info(`Task:_doRefer failed storing task data before REFER for ${this.queueName}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.logger.info(`Task:_doRefer: referring call to ${sipAddress} for ${this.queueName}`);
|
||||
this.callMoved = true;
|
||||
const success = await cs.referCall(`sip:context-${uuid}@${sipAddress}`);
|
||||
if (!success) {
|
||||
this.callMoved = false;
|
||||
this.logger.info('Task:_doRefer REFER failed');
|
||||
return success;
|
||||
}
|
||||
this.logger.info('Task:_doRefer REFER succeeded');
|
||||
return success;
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'Task:_doRefer error');
|
||||
}
|
||||
if (required.length > 0) throw new Error(`${name}: missing value for ${required}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,100 +1,683 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions, TranscriptionEvents} = require('../utils/constants');
|
||||
const assert = require('assert');
|
||||
const {
|
||||
TaskName,
|
||||
GoogleTranscriptionEvents,
|
||||
NuanceTranscriptionEvents,
|
||||
AwsTranscriptionEvents,
|
||||
AzureTranscriptionEvents,
|
||||
DeepgramTranscriptionEvents,
|
||||
SonioxTranscriptionEvents,
|
||||
CobaltTranscriptionEvents,
|
||||
IbmTranscriptionEvents,
|
||||
NvidiaTranscriptionEvents,
|
||||
JambonzTranscriptionEvents,
|
||||
TranscribeStatus,
|
||||
AssemblyAiTranscriptionEvents,
|
||||
VoxistTranscriptionEvents,
|
||||
OpenAITranscriptionEvents,
|
||||
VerbioTranscriptionEvents,
|
||||
SpeechmaticsTranscriptionEvents
|
||||
} = require('../utils/constants.json');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const SttTask = require('./stt-task');
|
||||
const { SpeechCredentialError } = require('../utils/error');
|
||||
|
||||
class TaskTranscribe extends Task {
|
||||
const STT_LISTEN_SPAN_NAME = 'stt-listen';
|
||||
|
||||
class TaskTranscribe extends SttTask {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
super(logger, opts, parentTask);
|
||||
|
||||
this.transcriptionHook = this.data.transcriptionHook;
|
||||
this.translationHook = this.data.translationHook;
|
||||
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
|
||||
if (this.data.recognizer) {
|
||||
this.language = this.data.recognizer.language || 'en-US';
|
||||
this.vendor = this.data.recognizer.vendor;
|
||||
this.interim = this.data.recognizer.interim === true;
|
||||
this.dualChannel = this.data.recognizer.dualChannel === true;
|
||||
this.interim = !!this.data.recognizer.interim;
|
||||
this.separateRecognitionPerChannel = this.data.recognizer.separateRecognitionPerChannel;
|
||||
}
|
||||
|
||||
/* for nested transcribe in dial, unless the app explicitly says so we want to transcribe both legs */
|
||||
if (this.parentTask?.name === TaskName.Dial) {
|
||||
if (this.data.channel === 1 || this.data.channel === 2) {
|
||||
/* transcribe only the channel specified */
|
||||
this.separateRecognitionPerChannel = false;
|
||||
this.channel = this.data.channel;
|
||||
logger.debug(`TaskTranscribe: transcribing only channel ${this.channel} in the Dial verb`);
|
||||
}
|
||||
else if (this.separateRecognitionPerChannel !== false) {
|
||||
this.separateRecognitionPerChannel = true;
|
||||
}
|
||||
else {
|
||||
this.channel = 1;
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.channel = 1;
|
||||
}
|
||||
|
||||
this.childSpan = [null, null];
|
||||
|
||||
// Continuous asr timeout
|
||||
this.asrTimeout = typeof this.data.recognizer.asrTimeout === 'number' ? this.data.recognizer.asrTimeout * 1000 : 0;
|
||||
if (this.asrTimeout > 0) {
|
||||
this.isContinuousAsr = true;
|
||||
}
|
||||
/* buffer speech for continuous asr */
|
||||
this._bufferedTranscripts = [ [], [] ]; // for channel 1 and 2
|
||||
this.bugname_prefix = 'transcribe_';
|
||||
this.paused = false;
|
||||
}
|
||||
|
||||
get name() { return TaskName.Transcribe; }
|
||||
|
||||
async exec(cs, ep, parentTask) {
|
||||
super.exec(cs);
|
||||
this.ep = ep;
|
||||
try {
|
||||
await this._startTranscribing(ep);
|
||||
await this.awaitTaskDone();
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'TaskTranscribe:exec - error');
|
||||
}
|
||||
ep.removeCustomEventListener(TranscriptionEvents.Transcription);
|
||||
ep.removeCustomEventListener(TranscriptionEvents.NoAudioDetected);
|
||||
ep.removeCustomEventListener(TranscriptionEvents.MaxDurationExceeded);
|
||||
get transcribing1() {
|
||||
return this.channel === 1 || this.separateRecognitionPerChannel;
|
||||
}
|
||||
|
||||
async kill() {
|
||||
super.kill();
|
||||
if (this.ep.connected) {
|
||||
this.ep.stopTranscription().catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
||||
get transcribing2() {
|
||||
return this.channel === 2 || this.separateRecognitionPerChannel && this.ep2;
|
||||
}
|
||||
|
||||
// hangup after 1 sec if we don't get a final transcription
|
||||
this._timer = setTimeout(() => this.notifyTaskDone(), 1000);
|
||||
async exec(cs, obj) {
|
||||
try {
|
||||
await this.handling(cs, obj);
|
||||
} catch (error) {
|
||||
if (error instanceof SpeechCredentialError) {
|
||||
this.logger.info('Transcribe failed due to SpeechCredentialError, finished!');
|
||||
this.notifyTaskDone();
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async handling(cs, {ep, ep2}) {
|
||||
await super.exec(cs, {ep, ep2});
|
||||
|
||||
if (this.data.recognizer.vendor === 'nuance') {
|
||||
this.data.recognizer.nuanceOptions = {
|
||||
// by default, nuance STT will recognize only 1st utterance.
|
||||
// enable multiple allow nuance detact all utterances
|
||||
utteranceDetectionMode: 'multiple',
|
||||
...this.data.recognizer.nuanceOptions
|
||||
};
|
||||
}
|
||||
|
||||
if (cs.hasGlobalSttHints) {
|
||||
const {hints, hintsBoost} = cs.globalSttHints;
|
||||
this.data.recognizer.hints = this.data.recognizer?.hints?.concat(hints);
|
||||
if (!this.data.recognizer.hintsBoost && hintsBoost) this.data.recognizer.hintsBoost = hintsBoost;
|
||||
this.logger.debug({hints: this.data.recognizer.hints, hintsBoost: this.data.recognizer.hintsBoost},
|
||||
'Transcribe:exec - applying global sttHints');
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.transcribing1) {
|
||||
await this._startTranscribing(cs, ep, 1);
|
||||
}
|
||||
if (this.transcribing2) {
|
||||
await this._startTranscribing(cs, ep2, 2);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!(await this._startFallback(cs, ep, {error: err}))) {
|
||||
this.logger.info(err, 'TaskTranscribe:exec - error');
|
||||
this.parentTask && this.parentTask.emit('error', err);
|
||||
this.removeCustomEventListeners();
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.awaitTaskDone();
|
||||
this.removeCustomEventListeners();
|
||||
}
|
||||
|
||||
async _stopTranscription() {
|
||||
let stopTranscription = false;
|
||||
if (this.transcribing1 && this.ep?.connected) {
|
||||
stopTranscription = true;
|
||||
this.ep.stopTranscription({
|
||||
vendor: this.vendor,
|
||||
bugname: this.bugname
|
||||
})
|
||||
.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})
|
||||
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
||||
}
|
||||
|
||||
return stopTranscription;
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
const stopTranscription = this._stopTranscription();
|
||||
// hangup after 1 sec if we don't get a final transcription
|
||||
if (stopTranscription) this._timer = setTimeout(() => this.notifyTaskDone(), 1500);
|
||||
else this.notifyTaskDone();
|
||||
|
||||
await this.awaitTaskDone();
|
||||
}
|
||||
|
||||
async _startTranscribing(ep) {
|
||||
const opts = {
|
||||
GOOGLE_SPEECH_USE_ENHANCED: true,
|
||||
GOOGLE_SPEECH_MODEL: 'phone_call'
|
||||
};
|
||||
if (this.hints) {
|
||||
Object.assign(opts, {'GOOGLE_SPEECH_HINTS': this.hints.join(',')});
|
||||
async updateTranscribe(status) {
|
||||
if (!this.killed && this.ep && this.ep.connected) {
|
||||
this.logger.info(`TaskTranscribe:updateTranscribe status ${status}`);
|
||||
switch (status) {
|
||||
case TranscribeStatus.Pause:
|
||||
this.paused = true;
|
||||
await this._stopTranscription();
|
||||
break;
|
||||
case TranscribeStatus.Resume:
|
||||
this.paused = false;
|
||||
if (this.transcribing1) await this._startTranscribing(this.cs, this.ep, 1);
|
||||
if (this.transcribing2) await this._startTranscribing(this.cs, this.ep2, 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (this.profanityFilter) {
|
||||
Object.assign(opts, {'GOOGLE_SPEECH_PROFANITY_FILTER': true});
|
||||
}
|
||||
|
||||
async _setSpeechHandlers(cs, ep, channel) {
|
||||
if (this[`_speechHandlersSet_${channel}`]) return;
|
||||
this[`_speechHandlersSet_${channel}`] = true;
|
||||
|
||||
/* some special deepgram logic */
|
||||
if (this.vendor === 'deepgram') {
|
||||
if (this.isContinuousAsr) this._doContinuousAsrWithDeepgram(this.asrTimeout);
|
||||
}
|
||||
if (this.dualChannel) {
|
||||
Object.assign(opts, {'GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL': true});
|
||||
|
||||
const opts = this.setChannelVarsForStt(this, this.sttCredentials, this.language, this.data.recognizer);
|
||||
switch (this.vendor) {
|
||||
case 'google':
|
||||
this.bugname = `${this.bugname_prefix}google_transcribe`;
|
||||
this.addCustomEventListener(ep, GoogleTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
this.addCustomEventListener(ep, GoogleTranscriptionEvents.NoAudioDetected,
|
||||
this._onNoAudio.bind(this, cs, ep, channel));
|
||||
this.addCustomEventListener(ep, GoogleTranscriptionEvents.MaxDurationExceeded,
|
||||
this._onMaxDurationExceeded.bind(this, cs, ep, channel));
|
||||
break;
|
||||
|
||||
case 'aws':
|
||||
case 'polly':
|
||||
this.bugname = `${this.bugname_prefix}aws_transcribe`;
|
||||
this.addCustomEventListener(ep, AwsTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
this.addCustomEventListener(ep, AwsTranscriptionEvents.NoAudioDetected,
|
||||
this._onNoAudio.bind(this, cs, ep, channel));
|
||||
this.addCustomEventListener(ep, AwsTranscriptionEvents.MaxDurationExceeded,
|
||||
this._onMaxDurationExceeded.bind(this, cs, ep, channel));
|
||||
break;
|
||||
case 'microsoft':
|
||||
this.bugname = `${this.bugname_prefix}azure_transcribe`;
|
||||
this.addCustomEventListener(ep, AzureTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
//this.addCustomEventListener(ep, AzureTranscriptionEvents.NoSpeechDetected,
|
||||
// this._onNoAudio.bind(this, cs, ep, channel));
|
||||
break;
|
||||
case 'nuance':
|
||||
this.bugname = `${this.bugname_prefix}nuance_transcribe`;
|
||||
this.addCustomEventListener(ep, NuanceTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
break;
|
||||
case 'deepgram':
|
||||
this.bugname = `${this.bugname_prefix}deepgram_transcribe`;
|
||||
this.addCustomEventListener(ep, DeepgramTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
this.addCustomEventListener(ep, DeepgramTranscriptionEvents.Connect,
|
||||
this._onVendorConnect.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, DeepgramTranscriptionEvents.ConnectFailure,
|
||||
this._onVendorConnectFailure.bind(this, cs, ep, channel));
|
||||
|
||||
/* if app sets deepgramOptions.utteranceEndMs they essentially want continuous asr */
|
||||
//if (opts.DEEPGRAM_SPEECH_UTTERANCE_END_MS) this.isContinuousAsr = true;
|
||||
|
||||
break;
|
||||
case 'soniox':
|
||||
this.bugname = `${this.bugname_prefix}soniox_transcribe`;
|
||||
this.addCustomEventListener(ep, SonioxTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
break;
|
||||
|
||||
case 'verbio':
|
||||
this.bugname = `${this.bugname_prefix}verbio_transcribe`;
|
||||
this.addCustomEventListener(
|
||||
ep, VerbioTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
break;
|
||||
|
||||
case 'cobalt':
|
||||
this.bugname = `${this.bugname_prefix}cobalt_transcribe`;
|
||||
this.addCustomEventListener(ep, CobaltTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
|
||||
/* cobalt doesnt have language, it has model, which is required */
|
||||
if (!this.data.recognizer.model) {
|
||||
throw new Error('Cobalt requires a model to be specified');
|
||||
}
|
||||
this.language = this.data.recognizer.model;
|
||||
|
||||
/* special case: if using hints with cobalt we need to compile them */
|
||||
this.hostport = opts.COBALT_SERVER_URI;
|
||||
if (this.vendor === 'cobalt' && opts.COBALT_SPEECH_HINTS) {
|
||||
try {
|
||||
const context = await this.compileHintsForCobalt(
|
||||
ep,
|
||||
opts.COBALT_SERVER_URI,
|
||||
this.data.recognizer.model,
|
||||
opts.COBALT_CONTEXT_TOKEN,
|
||||
opts.COBALT_SPEECH_HINTS
|
||||
);
|
||||
if (context) opts.COBALT_COMPILED_CONTEXT_DATA = context;
|
||||
delete opts.COBALT_SPEECH_HINTS;
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'Error compiling hints for cobalt');
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ibm':
|
||||
this.bugname = `${this.bugname_prefix}ibm_transcribe`;
|
||||
this.addCustomEventListener(ep, IbmTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
this.addCustomEventListener(ep, IbmTranscriptionEvents.Connect,
|
||||
this._onVendorConnect.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, IbmTranscriptionEvents.ConnectFailure,
|
||||
this._onVendorConnectFailure.bind(this, cs, ep, channel));
|
||||
break;
|
||||
|
||||
case 'nvidia':
|
||||
this.bugname = `${this.bugname_prefix}nvidia_transcribe`;
|
||||
this.addCustomEventListener(ep, NvidiaTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
break;
|
||||
|
||||
case 'assemblyai':
|
||||
this.bugname = `${this.bugname_prefix}assemblyai_transcribe`;
|
||||
this.addCustomEventListener(ep, AssemblyAiTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
this.addCustomEventListener(ep,
|
||||
AssemblyAiTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, AssemblyAiTranscriptionEvents.Error, this._onVendorError.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, AssemblyAiTranscriptionEvents.ConnectFailure,
|
||||
this._onVendorConnectFailure.bind(this, cs, ep, channel));
|
||||
break;
|
||||
|
||||
case 'voxist':
|
||||
this.bugname = `${this.bugname_prefix}voxist_transcribe`;
|
||||
this.addCustomEventListener(ep, VoxistTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
this.addCustomEventListener(ep,
|
||||
VoxistTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, VoxistTranscriptionEvents.Error, this._onVendorError.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, VoxistTranscriptionEvents.ConnectFailure,
|
||||
this._onVendorConnectFailure.bind(this, cs, ep, channel));
|
||||
break;
|
||||
|
||||
case 'speechmatics':
|
||||
this.bugname = `${this.bugname_prefix}speechmatics_transcribe`;
|
||||
this.addCustomEventListener(
|
||||
ep, SpeechmaticsTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep, channel));
|
||||
this.addCustomEventListener(
|
||||
ep, SpeechmaticsTranscriptionEvents.Translation, this._onTranslation.bind(this, cs, ep, channel));
|
||||
this.addCustomEventListener(ep, SpeechmaticsTranscriptionEvents.Info,
|
||||
this._onSpeechmaticsInfo.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, SpeechmaticsTranscriptionEvents.RecognitionStarted,
|
||||
this._onSpeechmaticsRecognitionStarted.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, SpeechmaticsTranscriptionEvents.Connect,
|
||||
this._onVendorConnect.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, SpeechmaticsTranscriptionEvents.ConnectFailure,
|
||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, SpeechmaticsTranscriptionEvents.Error,
|
||||
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`;
|
||||
this.addCustomEventListener(ep, JambonzTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
this.addCustomEventListener(ep, JambonzTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, JambonzTranscriptionEvents.ConnectFailure,
|
||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
||||
break;
|
||||
}
|
||||
else {
|
||||
this.notifyError({ msg: 'ASR error', details:`Invalid vendor ${this.vendor}`});
|
||||
this.notifyTaskDone();
|
||||
throw new Error(`Invalid vendor ${this.vendor}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* common handler for all stt engine errors */
|
||||
this.addCustomEventListener(ep, JambonzTranscriptionEvents.Error, this._onJambonzError.bind(this, cs, ep));
|
||||
await ep.set(opts)
|
||||
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing'));
|
||||
|
||||
ep.addCustomEventListener(TranscriptionEvents.Transcription, this._onTranscription.bind(this, ep));
|
||||
ep.addCustomEventListener(TranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, ep));
|
||||
ep.addCustomEventListener(TranscriptionEvents.MaxDurationExceeded, this._onMaxDurationExceeded.bind(this, ep));
|
||||
.catch((err) => this.logger.info(err, 'Error setting channel variables'));
|
||||
}
|
||||
|
||||
async _startTranscribing(cs, ep, channel) {
|
||||
await this._setSpeechHandlers(cs, ep, channel);
|
||||
await this._transcribe(ep);
|
||||
|
||||
/* start child span for this channel */
|
||||
const {span, ctx} = this.startChildSpan(`${STT_LISTEN_SPAN_NAME}:${channel}`);
|
||||
this.childSpan[channel - 1] = {span, ctx};
|
||||
}
|
||||
|
||||
async _transcribe(ep) {
|
||||
await this.ep.startTranscription({
|
||||
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,
|
||||
language: this.language || this.callSession.speechRecognizerLanguage,
|
||||
channels: this.dualChannel ? 2 : 1
|
||||
locale: this.language,
|
||||
channels: 1,
|
||||
bugname: this.bugname,
|
||||
hostport: this.hostport
|
||||
});
|
||||
}
|
||||
|
||||
_onTranscription(ep, evt) {
|
||||
this.logger.debug(evt, 'TaskTranscribe:_onTranscription');
|
||||
this.cs.requestor.request(this.transcriptionHook, Object.assign({speech: evt}, this.cs.callInfo))
|
||||
.catch((err) => this.logger.info(err, 'TranscribeTask:_onTranscription error'));
|
||||
async _onTranscription(cs, ep, channel, evt, fsEvent) {
|
||||
// make sure this is not a transcript from answering machine detection
|
||||
const bugname = fsEvent.getHeader('media-bugname');
|
||||
const finished = fsEvent.getHeader('transcription-session-finished');
|
||||
const bufferedTranscripts = this._bufferedTranscripts[channel - 1];
|
||||
if (bugname && this.bugname !== bugname) return;
|
||||
if (this.paused) {
|
||||
this.logger.debug({evt}, 'TaskTranscribe:_onTranscription - paused, ignoring transcript');
|
||||
}
|
||||
|
||||
if (this.vendor === 'ibm' && evt?.state === 'listening') return;
|
||||
|
||||
if (this.vendor === 'deepgram' && evt.type === 'UtteranceEnd') {
|
||||
/* we will only get this when we have set utterance_end_ms */
|
||||
|
||||
/* DH: send a speech event when we get UtteranceEnd if they want interim events */
|
||||
if (this.interim) {
|
||||
this.logger.debug('Gather:_onTranscription - got UtteranceEnd event from deepgram, sending speech event');
|
||||
this._resolve(channel, evt);
|
||||
}
|
||||
if (bufferedTranscripts.length === 0) {
|
||||
this.logger.debug('Gather:_onTranscription - got UtteranceEnd event from deepgram but no buffered transcripts');
|
||||
}
|
||||
else {
|
||||
this.logger.debug('Gather:_onTranscription - got UtteranceEnd event from deepgram, return buffered transcript');
|
||||
evt = this.consolidateTranscripts(bufferedTranscripts, channel, this.language, this.vendor);
|
||||
evt.is_final = true;
|
||||
this._bufferedTranscripts[channel - 1] = [];
|
||||
this._resolve(channel, evt);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.logger.debug({evt}, 'TaskTranscribe:_onTranscription - before normalization');
|
||||
|
||||
evt = this.normalizeTranscription(evt, this.vendor, channel, this.language, undefined,
|
||||
this.data.recognizer.punctuation);
|
||||
this.logger.debug({evt}, 'TaskTranscribe:_onTranscription');
|
||||
if (evt.alternatives.length === 0) {
|
||||
this.logger.info({evt}, 'TaskTranscribe:_onTranscription - got empty transcript, continue listening');
|
||||
return;
|
||||
}
|
||||
|
||||
let emptyTranscript = false;
|
||||
if (evt.is_final) {
|
||||
if (evt.alternatives.length === 0 || evt.alternatives[0].transcript === '' && !cs.callGone && !this.killed) {
|
||||
emptyTranscript = true;
|
||||
if (finished === 'true' &&
|
||||
['microsoft', 'deepgram'].includes(this.vendor) &&
|
||||
bufferedTranscripts.length === 0) {
|
||||
this.logger.debug({evt}, 'TaskGather:_onTranscription - got empty transcript from old gather, disregarding');
|
||||
return;
|
||||
}
|
||||
else if (this.vendor !== 'deepgram') {
|
||||
this.logger.info({evt}, 'TaskGather:_onTranscription - got empty transcript, continue listening');
|
||||
return;
|
||||
}
|
||||
else if (this.isContinuousAsr) {
|
||||
this.logger.info({evt},
|
||||
'TaskGather:_onTranscription - got empty deepgram transcript during continous asr, continue listening');
|
||||
return;
|
||||
}
|
||||
else if (this.vendor === 'deepgram' && bufferedTranscripts.length > 0) {
|
||||
this.logger.info({evt},
|
||||
'TaskGather:_onTranscription - got empty transcript from deepgram, return the buffered transcripts');
|
||||
}
|
||||
}
|
||||
if (this.isContinuousAsr) {
|
||||
/* append the transcript and start listening again for asrTimeout */
|
||||
const t = evt.alternatives[0].transcript;
|
||||
if (t) {
|
||||
/* remove trailing punctuation */
|
||||
if (/[,;:\.!\?]$/.test(t)) {
|
||||
this.logger.debug('TaskGather:_onTranscription - removing trailing punctuation');
|
||||
evt.alternatives[0].transcript = t.slice(0, -1);
|
||||
}
|
||||
}
|
||||
this.logger.info({evt}, 'TaskGather:_onTranscription - got transcript during continous asr');
|
||||
bufferedTranscripts.push(evt);
|
||||
this._startAsrTimer(channel);
|
||||
|
||||
/* some STT engines will keep listening after a final response, so no need to restart */
|
||||
if (!this.doesVendorContinueListeningAfterFinalTranscript(this.vendor)) {
|
||||
this._startTranscribing(cs, ep, channel);
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (this.vendor === 'soniox') {
|
||||
/* compile transcripts into one */
|
||||
this._sonioxTranscripts.push(evt.vendor.finalWords);
|
||||
evt = this.compileSonioxTranscripts(this._sonioxTranscripts, 1, this.language);
|
||||
this._sonioxTranscripts = [];
|
||||
}
|
||||
else if (this.vendor === 'deepgram') {
|
||||
/* compile transcripts into one */
|
||||
if (!emptyTranscript) bufferedTranscripts.push(evt);
|
||||
|
||||
/* deepgram can send an empty and final transcript; only if we have any buffered should we resolve */
|
||||
if (bufferedTranscripts.length === 0) return;
|
||||
evt = this.consolidateTranscripts(bufferedTranscripts, channel, this.language);
|
||||
this._bufferedTranscripts[channel - 1] = [];
|
||||
}
|
||||
|
||||
/* here is where we return a final transcript */
|
||||
this.logger.debug({evt}, 'TaskTranscribe:_onTranscription - sending final transcript');
|
||||
this._resolve(channel, evt);
|
||||
|
||||
if (!this.doesVendorContinueListeningAfterFinalTranscript(this.vendor)) {
|
||||
this.logger.debug('TaskTranscribe:_onTranscription - restarting transcribe');
|
||||
this._startTranscribing(cs, ep, channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
/* interim transcript */
|
||||
|
||||
/* deepgram can send a non-final transcript but with words that are final, so we need to buffer */
|
||||
if (this.vendor === 'deepgram') {
|
||||
const originalEvent = evt.vendor.evt;
|
||||
if (originalEvent.is_final && evt.alternatives[0].transcript !== '') {
|
||||
this.logger.debug({evt}, 'Gather:_onTranscription - buffering a completed (partial) deepgram transcript');
|
||||
bufferedTranscripts.push(evt);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.interim) {
|
||||
this.logger.debug({evt}, 'TaskTranscribe:_onTranscription - sending interim transcript');
|
||||
this._resolve(channel, evt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _onTranslation(_cs, _ep, channel, evt, _fsEvent) {
|
||||
this.logger.debug({evt}, 'TaskTranscribe:_onTranslation');
|
||||
if (this.translationHook && evt.results?.length > 0) {
|
||||
try {
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
const payload = {
|
||||
...this.cs.callInfo,
|
||||
...httpHeaders,
|
||||
translation: {
|
||||
channel,
|
||||
language: evt.language,
|
||||
translation: evt.results[0].content
|
||||
}
|
||||
};
|
||||
|
||||
this.logger.debug({payload}, 'sending translationHook');
|
||||
const json = await this.cs.requestor.request('verb:hook', this.translationHook, payload);
|
||||
this.logger.info({json}, 'completed translationHook');
|
||||
if (json && Array.isArray(json) && !this.parentTask) {
|
||||
const makeTask = require('./make_task');
|
||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
if (tasks && tasks.length > 0) {
|
||||
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
|
||||
this.cs.replaceApplication(tasks);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'TranscribeTask:_onTranslation error');
|
||||
}
|
||||
if (this.parentTask) {
|
||||
this.parentTask.emit('translation', evt);
|
||||
}
|
||||
}
|
||||
if (this.killed) {
|
||||
this.logger.debug('TaskTranscribe:_onTranscription exiting after receiving final transcription');
|
||||
this.logger.debug('TaskTranscribe:_onTranslation exiting after receiving final transcription');
|
||||
this._clearTimer();
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
_onNoAudio(ep) {
|
||||
this.logger.debug('TaskTranscribe:_onNoAudio restarting transcription');
|
||||
this._transcribe(ep);
|
||||
async _resolve(channel, evt) {
|
||||
if (evt.is_final) {
|
||||
/* 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)
|
||||
});
|
||||
this.childSpan[channel - 1].span.end();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.transcriptionHook) {
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
const payload = {
|
||||
...this.cs.callInfo,
|
||||
...httpHeaders,
|
||||
...(evt.alternatives && {speech: evt}),
|
||||
...(evt.type && {speechEvent: evt})
|
||||
};
|
||||
try {
|
||||
this.logger.debug({payload}, 'sending transcriptionHook');
|
||||
const json = await this.cs.requestor.request('verb:hook', this.transcriptionHook, payload);
|
||||
this.logger.info({json}, 'completed transcriptionHook');
|
||||
if (json && Array.isArray(json) && !this.parentTask) {
|
||||
const makeTask = require('./make_task');
|
||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
if (tasks && tasks.length > 0) {
|
||||
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
|
||||
this.cs.replaceApplication(tasks);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'TranscribeTask:_onTranscription error');
|
||||
}
|
||||
}
|
||||
if (this.parentTask) {
|
||||
this.parentTask.emit('transcription', evt);
|
||||
}
|
||||
if (this.killed) {
|
||||
this.logger.debug('TaskTranscribe:_onTranscription exiting after receiving final transcription');
|
||||
this._clearTimer();
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
else if (evt.is_final) {
|
||||
/* start another child span for this channel */
|
||||
const {span, ctx} = this.startChildSpan(`${STT_LISTEN_SPAN_NAME}:${channel}`);
|
||||
this.childSpan[channel - 1] = {span, ctx};
|
||||
}
|
||||
}
|
||||
|
||||
_onMaxDurationExceeded(ep) {
|
||||
this.logger.debug('TaskTranscribe:_onMaxDurationExceeded restarting transcription');
|
||||
_onNoAudio(cs, ep, channel) {
|
||||
this.logger.debug(`TaskTranscribe:_onNoAudio 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': 'timeout',
|
||||
'stt.label': this.label || 'None',
|
||||
});
|
||||
this.childSpan[channel - 1].span.end();
|
||||
}
|
||||
this._transcribe(ep);
|
||||
|
||||
/* start new child span for this channel */
|
||||
const {span, ctx} = this.startChildSpan(`${STT_LISTEN_SPAN_NAME}:${channel}`);
|
||||
this.childSpan[channel - 1] = {span, ctx};
|
||||
}
|
||||
|
||||
_onMaxDurationExceeded(cs, ep, channel) {
|
||||
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': reason,
|
||||
'stt.label': this.label || 'None',
|
||||
});
|
||||
this.childSpan[channel - 1].span.end();
|
||||
}
|
||||
|
||||
this._transcribe(ep);
|
||||
|
||||
/* start new child span for this channel */
|
||||
const {span, ctx} = this.startChildSpan(`${STT_LISTEN_SPAN_NAME}:${channel}`);
|
||||
this.childSpan[channel - 1] = {span, ctx};
|
||||
}
|
||||
|
||||
_clearTimer() {
|
||||
@@ -103,6 +686,126 @@ class TaskTranscribe extends Task {
|
||||
this._timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
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}`));
|
||||
try {
|
||||
this.notifyError({ msg: 'ASR error',
|
||||
details:`STT Vendor ${this.vendor} error: ${evt.error || evt.reason}`, failover: 'in progress'});
|
||||
await this._initFallback();
|
||||
let channel = 1;
|
||||
if (this.ep !== _ep) {
|
||||
channel = 2;
|
||||
}
|
||||
this[`_speechHandlersSet_${channel}`] = false;
|
||||
this._startTranscribing(cs, _ep, channel);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.notifyError({ msg: 'ASR error',
|
||||
details:`STT Vendor ${this.vendor} error: ${evt.error || evt.reason}`, failover: 'not available'});
|
||||
this.logger.info({error}, `There is error while falling back to ${this.fallbackVendor}`);
|
||||
}
|
||||
} else {
|
||||
this.logger.debug('transcribe:_startFallback no condition for falling back');
|
||||
this.notifyError({ msg: 'ASR error',
|
||||
details:`STT Vendor ${this.vendor} error: ${evt.error || evt.reason}`, failover: 'not available'});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async _onJambonzError(cs, _ep, evt) {
|
||||
if (this.vendor === 'google' && evt.error_code === 0) {
|
||||
this.logger.info({evt}, 'TaskTranscribe:_onJambonzError - ignoring google error code 0');
|
||||
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;
|
||||
|
||||
if (this.vendor === 'nuance') {
|
||||
const {code, error} = evt;
|
||||
if (code === 404 && error === 'No speech') return this._resolve('timeout');
|
||||
if (code === 413 && error === 'Too much speech') return this._resolve('timeout');
|
||||
}
|
||||
this.logger.info({evt}, 'TaskTranscribe:_onJambonzError');
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.STT_FAILURE,
|
||||
message: `Custom speech vendor ${this.vendor} error: ${evt.error}`,
|
||||
vendor: this.vendor,
|
||||
target_sid: cs.callSid
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for jambonz custom connection failure'));
|
||||
if (!(await this._startFallback(cs, _ep, evt))) {
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
async _onVendorConnectFailure(cs, _ep, channel, evt) {
|
||||
super._onVendorConnectFailure(cs, _ep, evt);
|
||||
if (this.childSpan[channel - 1] && this.childSpan[channel - 1].span) {
|
||||
this.childSpan[channel - 1].span.setAttributes({
|
||||
channel,
|
||||
'stt.resolve': 'connection failure',
|
||||
'stt.label': this.label || 'None',
|
||||
});
|
||||
this.childSpan[channel - 1].span.end();
|
||||
}
|
||||
if (!(await this._startFallback(cs, _ep, evt))) {
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
async _onSpeechmaticsRecognitionStarted(_cs, _ep, evt) {
|
||||
this.logger.debug({evt}, 'TaskGather:_onSpeechmaticsRecognitionStarted');
|
||||
}
|
||||
|
||||
async _onSpeechmaticsInfo(_cs, _ep, evt) {
|
||||
this.logger.debug({evt}, 'TaskGather:_onSpeechmaticsInfo');
|
||||
}
|
||||
|
||||
async _onSpeechmaticsError(cs, _ep, evt) {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const {message, ...e} = evt;
|
||||
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);
|
||||
this._clearAsrTimer(channel);
|
||||
this._asrTimer = setTimeout(() => {
|
||||
this.logger.debug(`TaskTranscribe:_startAsrTimer - asr timer went off for channel: ${channel}`);
|
||||
const evt = this.consolidateTranscripts(
|
||||
this._bufferedTranscripts[channel - 1], channel, this.language, this.vendor);
|
||||
this._bufferedTranscripts[channel - 1] = [];
|
||||
this._resolve(channel, evt);
|
||||
}, this.asrTimeout);
|
||||
this.logger.debug(`TaskTranscribe:_startAsrTimer: set for ${this.asrTimeout}ms for channel ${channel}`);
|
||||
}
|
||||
|
||||
_clearAsrTimer(channel) {
|
||||
if (this._asrTimer) clearTimeout(this._asrTimer);
|
||||
this._asrTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskTranscribe;
|
||||
|
||||
336
lib/tasks/tts-task.js
Normal file
336
lib/tasks/tts-task.js
Normal file
@@ -0,0 +1,336 @@
|
||||
const Task = require('./task');
|
||||
const { TaskPreconditions } = require('../utils/constants');
|
||||
const { SpeechCredentialError } = require('../utils/error');
|
||||
const dbUtils = require('../utils/db-utils');
|
||||
|
||||
class TtsTask extends Task {
|
||||
|
||||
constructor(logger, data, parentTask) {
|
||||
super(logger, data);
|
||||
this.parentTask = parentTask;
|
||||
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
|
||||
/**
|
||||
* Task use taskIncludeSynthesizer to identify
|
||||
* if taskIncludeSynthesizer === true, use label from verb.synthesizer, even it's empty
|
||||
* if taskIncludeSynthesizer === false, use label from application.synthesizer
|
||||
*/
|
||||
this.taskIncludeSynthesizer = !!this.data.synthesizer;
|
||||
this.synthesizer = this.data.synthesizer || {};
|
||||
this.disableTtsCache = this.data.disableTtsCache;
|
||||
this.options = this.synthesizer.options || {};
|
||||
this.instructions = this.data.instructions;
|
||||
}
|
||||
|
||||
async exec(cs) {
|
||||
super.exec(cs);
|
||||
if (cs.synthesizer) {
|
||||
this.options = {...cs.synthesizer.options, ...this.options};
|
||||
this.data.synthesizer = this.data.synthesizer || {};
|
||||
for (const k in cs.synthesizer) {
|
||||
const newValue = this.data.synthesizer && this.data.synthesizer[k] !== undefined ?
|
||||
this.data.synthesizer[k] :
|
||||
cs.synthesizer[k];
|
||||
|
||||
if (Array.isArray(newValue)) {
|
||||
this.data.synthesizer[k] = [...(this.data.synthesizer[k] || []), ...cs.synthesizer[k]];
|
||||
} else if (typeof newValue === 'object' && newValue !== null) {
|
||||
this.data.synthesizer[k] = { ...(this.data.synthesizer[k] || {}), ...cs.synthesizer[k] };
|
||||
} else {
|
||||
this.data.synthesizer[k] = newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
const vendor = this.synthesizer.vendor && this.synthesizer.vendor !== 'default' ?
|
||||
this.synthesizer.vendor :
|
||||
cs.speechSynthesisVendor;
|
||||
const language = this.synthesizer.language && this.synthesizer.language !== 'default' ?
|
||||
this.synthesizer.language :
|
||||
cs.speechSynthesisLanguage ;
|
||||
const voice = this.synthesizer.voice && this.synthesizer.voice !== 'default' ?
|
||||
this.synthesizer.voice :
|
||||
cs.speechSynthesisVoice;
|
||||
const label = this.taskIncludeSynthesizer ? this.synthesizer.label : cs.speechSynthesisLabel;
|
||||
return {vendor, language, voice, label};
|
||||
}
|
||||
|
||||
async setTtsStreamingChannelVars(vendor, language, voice, credentials, ep) {
|
||||
const {api_key, model_id, custom_tts_streaming_url, auth_token} = credentials;
|
||||
let obj;
|
||||
|
||||
this.logger.debug({credentials},
|
||||
`setTtsStreamingChannelVars: vendor: ${vendor}, language: ${language}, voice: ${voice}`);
|
||||
|
||||
switch (vendor) {
|
||||
case 'deepgram':
|
||||
obj = {
|
||||
DEEPGRAM_API_KEY: api_key,
|
||||
DEEPGRAM_TTS_STREAMING_MODEL: voice
|
||||
};
|
||||
break;
|
||||
case 'cartesia':
|
||||
obj = {
|
||||
CARTESIA_API_KEY: api_key,
|
||||
CARTESIA_TTS_STREAMING_MODEL_ID: model_id,
|
||||
CARTESIA_TTS_STREAMING_VOICE_ID: voice,
|
||||
CARTESIA_TTS_STREAMING_LANGUAGE: language || 'en',
|
||||
};
|
||||
break;
|
||||
case 'elevenlabs':
|
||||
const {stability, similarity_boost, use_speaker_boost, style, speed} = this.options.voice_settings || {};
|
||||
obj = {
|
||||
ELEVENLABS_API_KEY: api_key,
|
||||
ELEVENLABS_TTS_STREAMING_MODEL_ID: 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}),
|
||||
...(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}),
|
||||
// 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}`}),
|
||||
...(this.options.pronunciation_dictionary_locators &&
|
||||
Array.isArray(this.options.pronunciation_dictionary_locators) && {
|
||||
ELEVENLABS_TTS_STREAMING_PRONUNCIATION_DICTIONARY_LOCATORS:
|
||||
JSON.stringify(this.options.pronunciation_dictionary_locators)
|
||||
}),
|
||||
};
|
||||
break;
|
||||
case 'rimelabs':
|
||||
const {
|
||||
pauseBetweenBrackets, phonemizeBetweenBrackets, inlineSpeedAlpha, speedAlpha, reduceLatency
|
||||
} = this.options;
|
||||
obj = {
|
||||
RIMELABS_API_KEY: api_key,
|
||||
RIMELABS_TTS_STREAMING_MODEL_ID: model_id,
|
||||
RIMELABS_TTS_STREAMING_VOICE_ID: voice,
|
||||
RIMELABS_TTS_STREAMING_LANGUAGE: language || 'en',
|
||||
...(pauseBetweenBrackets && {RIMELABS_TTS_STREAMING_PAUSE_BETWEEN_BRACKETS: pauseBetweenBrackets}),
|
||||
...(phonemizeBetweenBrackets &&
|
||||
{RIMELABS_TTS_STREAMING_PHONEMIZE_BETWEEN_BRACKETS: phonemizeBetweenBrackets}),
|
||||
...(inlineSpeedAlpha && {RIMELABS_TTS_STREAMING_INLINE_SPEED_ALPHA: inlineSpeedAlpha}),
|
||||
...(speedAlpha && {RIMELABS_TTS_STREAMING_SPEED_ALPHA: speedAlpha}),
|
||||
...(reduceLatency && {RIMELABS_TTS_STREAMING_REDUCE_LATENCY: reduceLatency})
|
||||
};
|
||||
break;
|
||||
default:
|
||||
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_VOICE_ID: voice,
|
||||
CUSTOM_TTS_STREAMING_LANGUAGE: language || 'en',
|
||||
CUSTOM_TTS_STREAMING_USE_TLS: use_tls
|
||||
};
|
||||
} else {
|
||||
throw new Error(`vendor ${vendor} is not supported for tts streaming yet`);
|
||||
}
|
||||
}
|
||||
this.logger.debug({vendor, credentials, obj}, 'setTtsStreamingChannelVars');
|
||||
|
||||
await ep.set(obj);
|
||||
}
|
||||
|
||||
async _synthesizeWithSpecificVendor(cs, ep, {vendor, language, voice, label, preCache = false}) {
|
||||
const {srf, accountSid:account_sid} = cs;
|
||||
const {writeAlerts, AlertType, stats} = srf.locals;
|
||||
const {synthAudio} = srf.locals.dbHelpers;
|
||||
const engine = this.synthesizer.engine || cs.synthesizer?.engine || 'neural';
|
||||
const salt = cs.callSid;
|
||||
|
||||
let credentials = cs.getSpeechCredentials(vendor, 'tts', label);
|
||||
if (!credentials) {
|
||||
throw new SpeechCredentialError(
|
||||
`No text-to-speech service credentials for ${vendor} with labels: ${label} have been configured`);
|
||||
}
|
||||
/* parse Nuance voices into name and model */
|
||||
if (vendor === 'nuance' && voice) {
|
||||
const arr = /([A-Za-z-]*)\s+-\s+(enhanced|standard)/.exec(voice);
|
||||
if (arr) {
|
||||
voice = arr[1];
|
||||
this.model = arr[2];
|
||||
}
|
||||
} else if (vendor === 'deepgram') {
|
||||
this.model = voice;
|
||||
}
|
||||
|
||||
/* allow for microsoft custom region voice and api_key to be specified as an override */
|
||||
if (vendor === 'microsoft' && this.options.deploymentId) {
|
||||
credentials = credentials || {};
|
||||
credentials.use_custom_tts = true;
|
||||
credentials.custom_tts_endpoint = this.options.deploymentId;
|
||||
credentials.api_key = this.options.apiKey || credentials.apiKey;
|
||||
credentials.region = this.options.region || credentials.region;
|
||||
voice = this.options.voice || voice;
|
||||
} else if (vendor === 'elevenlabs') {
|
||||
credentials = credentials || {};
|
||||
credentials.model_id = this.options.model_id || credentials.model_id;
|
||||
credentials.voice_settings = this.options.voice_settings || {};
|
||||
credentials.optimize_streaming_latency = this.options.optimize_streaming_latency
|
||||
|| credentials.optimize_streaming_latency;
|
||||
voice = this.options.voice_id || voice;
|
||||
} else if (vendor === 'rimelabs') {
|
||||
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;
|
||||
} else if (vendor === 'verbio') {
|
||||
credentials = credentials || {};
|
||||
credentials.engine_version = this.options.engine_version || credentials.engine_version;
|
||||
} else if (vendor === 'playht') {
|
||||
credentials = credentials || {};
|
||||
credentials.voice_engine = this.options.voice_engine || credentials.voice_engine;
|
||||
} else if (vendor === 'google' && typeof voice === 'string' && voice.startsWith('custom_')) {
|
||||
const {lookupGoogleCustomVoice} = dbUtils(this.logger, cs.srf);
|
||||
const arr = /custom_(.*)/.exec(voice);
|
||||
if (arr) {
|
||||
const google_custom_voice_sid = arr[1];
|
||||
const [custom_voice] = await lookupGoogleCustomVoice(google_custom_voice_sid);
|
||||
if (custom_voice.use_voice_cloning_key) {
|
||||
voice = {
|
||||
voice_cloning_key: custom_voice.voice_cloning_key,
|
||||
};
|
||||
}
|
||||
}
|
||||
} 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:
|
||||
* 1. Stream tts on vendor A with cache_speech_handles=1, then
|
||||
* 2. Stream tts on vendor B with cache_speech_handles=1
|
||||
*
|
||||
* we previously tried to track when vendors were switched and manage the flag accordingly,
|
||||
* but it difficult to track all the scenarios and the benefit (slightly faster start to tts playout)
|
||||
* is probably minimal. DH.
|
||||
*/
|
||||
ep.set({
|
||||
tts_engine: vendor.startsWith('custom:') ? 'custom' : vendor,
|
||||
tts_voice: voice,
|
||||
//cache_speech_handles: !cs.currentTtsVendor || cs.currentTtsVendor === vendor ? 1 : 0,
|
||||
cache_speech_handles: 0,
|
||||
}).catch((err) => this.logger.info({err}, 'Error setting tts_engine on endpoint'));
|
||||
// set the current vendor on the call session
|
||||
// If vendor is changed from the previous one, then reset the cache_speech_handles flag
|
||||
//cs.currentTtsVendor = vendor;
|
||||
|
||||
if (!preCache && !this._disableTracing)
|
||||
this.logger.debug({vendor, language, voice, model: this.model}, 'TaskSay:exec');
|
||||
try {
|
||||
if (!credentials) {
|
||||
writeAlerts({
|
||||
account_sid,
|
||||
alert_type: AlertType.TTS_NOT_PROVISIONED,
|
||||
vendor,
|
||||
target_sid: cs.callSid
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for no tts'));
|
||||
throw new SpeechCredentialError('no provisioned speech credentials for TTS');
|
||||
}
|
||||
|
||||
/* produce an audio segment from the provided text */
|
||||
const generateAudio = async(text) => {
|
||||
if (this.killed) return;
|
||||
if (text.startsWith('silence_stream://')) return text;
|
||||
|
||||
/* otel: trace time for tts */
|
||||
if (!preCache && !this._disableTracing) {
|
||||
const {span} = this.startChildSpan('tts-generation', {
|
||||
'tts.vendor': vendor,
|
||||
'tts.language': language,
|
||||
'tts.voice': voice,
|
||||
'tts.label': label || 'None',
|
||||
});
|
||||
this.otelSpan = span;
|
||||
}
|
||||
try {
|
||||
const {filePath, servedFromCache, rtt} = await synthAudio(stats, {
|
||||
account_sid,
|
||||
text,
|
||||
instructions: this.instructions,
|
||||
vendor,
|
||||
language,
|
||||
voice,
|
||||
engine,
|
||||
model: this.model,
|
||||
salt,
|
||||
credentials,
|
||||
options: this.options,
|
||||
disableTtsCache : this.disableTtsCache,
|
||||
renderForCaching: preCache
|
||||
});
|
||||
if (!filePath.startsWith('say:')) {
|
||||
this.logger.debug(`Say: file ${filePath}, served from cache ${servedFromCache}`);
|
||||
if (filePath) cs.trackTmpFile(filePath);
|
||||
if (this.otelSpan) {
|
||||
this.otelSpan.setAttributes({'tts.cached': servedFromCache});
|
||||
this.otelSpan.end();
|
||||
this.otelSpan = null;
|
||||
}
|
||||
if (!servedFromCache && rtt && !preCache && !this._disableTracing) {
|
||||
this.notifyStatus({
|
||||
event: 'synthesized-audio',
|
||||
vendor,
|
||||
language,
|
||||
characters: text.length,
|
||||
elapsedTime: rtt
|
||||
});
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.logger.debug('Say: a streaming tts api will be used');
|
||||
const modifiedPath = filePath.replace('say:{', `say:{session-uuid=${ep.uuid},`);
|
||||
return modifiedPath;
|
||||
}
|
||||
return filePath;
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Error synthesizing tts');
|
||||
if (this.otelSpan) this.otelSpan.end();
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.TTS_FAILURE,
|
||||
vendor,
|
||||
detail: err.message,
|
||||
target_sid: cs.callSid
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for tts failure'));
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const arr = this.text.map((t) => (this._validateURL(t) ? t : generateAudio(t)));
|
||||
return (await Promise.all(arr)).filter((fp) => fp && fp.length);
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'TaskSay:exec error');
|
||||
throw err;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
_validateURL(urlString) {
|
||||
try {
|
||||
new URL(urlString);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TtsTask;
|
||||
187
lib/utils/action-hook-delay.js
Normal file
187
lib/utils/action-hook-delay.js
Normal file
@@ -0,0 +1,187 @@
|
||||
const makeTask = require('../tasks/make_task');
|
||||
const Emitter = require('events');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const {TaskName} = require('../utils/constants');
|
||||
|
||||
/**
|
||||
* ActionHookDelayProcessor
|
||||
* @extends Emitter
|
||||
*
|
||||
* @param {Object} logger - logger instance
|
||||
* @param {Object} opts - options
|
||||
* @param {Object} cs - call session
|
||||
* @param {Object} ep - endpoint
|
||||
*
|
||||
* @emits {Event} 'giveup' - when associated giveup timer expires
|
||||
*
|
||||
* Ref:https://www.jambonz.org/docs/supporting-articles/handling-action-hook-delays/
|
||||
*/
|
||||
class ActionHookDelayProcessor extends Emitter {
|
||||
constructor(logger, opts, cs) {
|
||||
super();
|
||||
this.logger = logger;
|
||||
this.cs = cs;
|
||||
this._active = false;
|
||||
|
||||
const enabled = this.init(opts);
|
||||
if (enabled && this.noResponseTimeout &&
|
||||
(!this.actions || !Array.isArray(this.actions) || this.actions.length === 0)) {
|
||||
throw new Error('ActionHookDelayProcessor: no actions specified');
|
||||
}
|
||||
else if (enabled && this.actions &&
|
||||
this.actions.some((a) => !a.verb || ![TaskName.Say, TaskName.Play].includes(a.verb))) {
|
||||
throw new Error(`ActionHookDelayProcessor: invalid actions specified: ${JSON.stringify(this.actions)}`);
|
||||
}
|
||||
}
|
||||
|
||||
get properties() {
|
||||
return {
|
||||
actions: this.actions,
|
||||
retries: this.retries,
|
||||
noResponseTimeout: this.noResponseTimeout,
|
||||
noResponseGiveUpTimeout: this.noResponseGiveUpTimeout
|
||||
};
|
||||
}
|
||||
|
||||
get ep() {
|
||||
return this.cs.ep;
|
||||
}
|
||||
|
||||
init(opts) {
|
||||
this.logger.debug({opts}, 'ActionHookDelayProcessor#init');
|
||||
|
||||
this.actions = opts.actions;
|
||||
this.retries = opts.retries || 0;
|
||||
this.noResponseTimeout = opts.noResponseTimeout;
|
||||
this.noResponseGiveUpTimeout = opts.noResponseGiveUpTimeout;
|
||||
this.giveUpActions = opts.giveUpActions;
|
||||
|
||||
// return false if these options actually disable the ahdp
|
||||
return ('enable' in opts && opts.enable === true) ||
|
||||
('enabled' in opts && opts.enabled === true) ||
|
||||
(!('enable' in opts) && !('enabled' in opts));
|
||||
}
|
||||
|
||||
start() {
|
||||
this.logger.debug('ActionHookDelayProcessor#start');
|
||||
if (this._active) {
|
||||
this.logger.debug('ActionHookDelayProcessor#start: already started due to prior gather which is continuing');
|
||||
return;
|
||||
}
|
||||
this._active = true;
|
||||
this._retryCount = 0;
|
||||
if (this.noResponseTimeout > 0) {
|
||||
const timeoutMs = this.noResponseTimeout * 1000;
|
||||
this._noResponseTimer = setTimeout(this._onNoResponseTimer.bind(this), timeoutMs);
|
||||
} else {
|
||||
this.logger.debug(
|
||||
'ActionHookDelayProcessor#start: noResponseTimeout is 0 or undefined hence not calling _onNoResponseTimer'
|
||||
);
|
||||
}
|
||||
|
||||
if (this.noResponseGiveUpTimeout > 0) {
|
||||
const timeoutMs = this.noResponseGiveUpTimeout * 1000;
|
||||
this._noResponseGiveUpTimer = setTimeout(this._onNoResponseGiveUpTimer.bind(this), timeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this._active = false;
|
||||
|
||||
if (this._noResponseTimer) {
|
||||
clearTimeout(this._noResponseTimer);
|
||||
this._noResponseTimer = null;
|
||||
}
|
||||
if (this._noResponseGiveUpTimer) {
|
||||
clearTimeout(this._noResponseGiveUpTimer);
|
||||
this._noResponseGiveUpTimer = null;
|
||||
}
|
||||
if (this._taskInProgress) {
|
||||
this.logger.debug(`ActionHookDelayProcessor#stop: stopping ${this._taskInProgress.name}`);
|
||||
|
||||
this._sayResolver = () => {
|
||||
this.logger.debug('ActionHookDelayProcessor#stop: play/say is done, continue on..');
|
||||
//this._taskInProgress.kill(this.cs);
|
||||
this._taskInProgress = null;
|
||||
};
|
||||
|
||||
/* we let Say finish, but interrupt Play */
|
||||
if (TaskName.Play === this._taskInProgress.name) {
|
||||
await this._taskInProgress.kill(this.cs);
|
||||
}
|
||||
return new Promise((resolve) => this._sayResolver = resolve);
|
||||
}
|
||||
this.logger.debug('ActionHookDelayProcessor#stop returning');
|
||||
}
|
||||
|
||||
_onNoResponseTimer() {
|
||||
this.logger.debug('ActionHookDelayProcessor#_onNoResponseTimer');
|
||||
this._noResponseTimer = null;
|
||||
|
||||
/* get the next play or say action */
|
||||
const verb = this.actions[this._retryCount % this.actions.length];
|
||||
|
||||
const t = normalizeJambones(this.logger, [verb]);
|
||||
this.logger.debug({verb}, 'ActionHookDelayProcessor#_onNoResponseTimer: starting action');
|
||||
try {
|
||||
this._taskInProgress = makeTask(this.logger, t[0]);
|
||||
this._taskInProgress.disableTracing = true;
|
||||
this._taskInProgress.exec(this.cs, {ep: this.ep}).catch((err) => {
|
||||
this.logger.info(`ActionHookDelayProcessor#_onNoResponseTimer: error playing file: ${err.message}`);
|
||||
this._taskInProgress = null;
|
||||
this.ep.removeAllListeners('playback-start');
|
||||
this.ep.removeAllListeners('playback-stop');
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'ActionHookDelayProcessor#_onNoResponseTimer: error starting action');
|
||||
this._taskInProgress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.ep.once('playback-start', (evt) => {
|
||||
this.logger.debug({evt}, 'got playback-start');
|
||||
if (!this._active) {
|
||||
this.logger.info({evt}, 'ActionHookDelayProcessor#_onNoResponseTimer: killing audio immediately');
|
||||
|
||||
/* note: in race condition we may have just hung up and cs.ep cleared */
|
||||
this.ep?.api('uuid_break', this.ep?.uuid)
|
||||
.catch((err) => this.logger.info(err,
|
||||
'ActionHookDelayProcessor#_onNoResponseTimer Error killing audio'));
|
||||
}
|
||||
});
|
||||
|
||||
this.ep.once('playback-stop', (evt) => {
|
||||
this._taskInProgress = null;
|
||||
if (this._sayResolver) {
|
||||
/* we were waiting for the play to finish before continuing to next task */
|
||||
this.logger.debug({evt}, 'ActionHookDelayProcessor#_onNoResponseTimer got playback-stop');
|
||||
this._sayResolver();
|
||||
this._sayResolver = null;
|
||||
}
|
||||
else {
|
||||
/* possibly start the no response timer again */
|
||||
if (this._active && this.retries > 0 && this._retryCount < this.retries && this.noResponseTimeout > 0) {
|
||||
this.logger.debug({evt}, 'ActionHookDelayProcessor#_onNoResponseTimer: playback-stop on play/say action');
|
||||
const timeoutMs = this.noResponseTimeout * 1000;
|
||||
this._noResponseTimer = setTimeout(this._onNoResponseTimer.bind(this), timeoutMs);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this._retryCount++;
|
||||
}
|
||||
|
||||
_onNoResponseGiveUpTimer() {
|
||||
this._active = false;
|
||||
if (!this.giveUpActions) {
|
||||
this.logger.info('ActionHookDelayProcessor#_onNoResponseGiveUpTimer');
|
||||
this.stop().catch((err) => {});
|
||||
this.emit('giveup');
|
||||
} else {
|
||||
this.logger.info('ActionHookDelayProcessor#_onNoResponseGiveUpTimer - giveUpActions');
|
||||
this.emit('giveupWithTasks', this.giveUpActions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ActionHookDelayProcessor;
|
||||
430
lib/utils/amd-utils.js
Normal file
430
lib/utils/amd-utils.js
Normal file
@@ -0,0 +1,430 @@
|
||||
const Emitter = require('events');
|
||||
const {readFile} = require('fs');
|
||||
const {
|
||||
TaskName,
|
||||
GoogleTranscriptionEvents,
|
||||
AwsTranscriptionEvents,
|
||||
AzureTranscriptionEvents,
|
||||
NuanceTranscriptionEvents,
|
||||
NvidiaTranscriptionEvents,
|
||||
IbmTranscriptionEvents,
|
||||
SonioxTranscriptionEvents,
|
||||
CobaltTranscriptionEvents,
|
||||
DeepgramTranscriptionEvents,
|
||||
JambonzTranscriptionEvents,
|
||||
AmdEvents,
|
||||
AvmdEvents
|
||||
} = require('./constants');
|
||||
const bugname = 'amd_bug';
|
||||
const {VMD_HINTS_FILE} = require('../config');
|
||||
let voicemailHints = [];
|
||||
|
||||
const updateHints = async(file, callback) => {
|
||||
readFile(file, 'utf8', (err, data) => {
|
||||
if (err) return callback(err);
|
||||
try {
|
||||
callback(null, JSON.parse(data));
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (VMD_HINTS_FILE) {
|
||||
updateHints(VMD_HINTS_FILE, (err, hints) => {
|
||||
if (err) { console.error(err); }
|
||||
voicemailHints = hints;
|
||||
|
||||
/* if successful, update the hints every hour */
|
||||
setInterval(() => {
|
||||
updateHints(VMD_HINTS_FILE, (err, hints) => {
|
||||
if (err) { console.error(err); }
|
||||
voicemailHints = hints;
|
||||
});
|
||||
}, 60000);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
class Amd extends Emitter {
|
||||
constructor(logger, cs, opts) {
|
||||
super();
|
||||
this.logger = logger;
|
||||
this.vendor = opts.recognizer?.vendor || cs.speechRecognizerVendor;
|
||||
if ('default' === this.vendor) this.vendor = cs.speechRecognizerVendor;
|
||||
|
||||
this.language = opts.recognizer?.language || cs.speechRecognizerLanguage;
|
||||
if ('default' === this.language) this.language = cs.speechRecognizerLanguage;
|
||||
|
||||
this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt',
|
||||
opts.recognizer?.label || cs.speechRecognizerLabel);
|
||||
|
||||
if (!this.sttCredentials) throw new Error(`No speech credentials found for vendor ${this.vendor}`);
|
||||
|
||||
this.thresholdWordCount = opts.thresholdWordCount || 9;
|
||||
const {normalizeTranscription} = require('./transcription-utils')(logger);
|
||||
this.normalizeTranscription = normalizeTranscription;
|
||||
const {getNuanceAccessToken, getIbmAccessToken} = cs.srf.locals.dbHelpers;
|
||||
this.getNuanceAccessToken = getNuanceAccessToken;
|
||||
this.getIbmAccessToken = getIbmAccessToken;
|
||||
const {setChannelVarsForStt} = require('./transcription-utils')(logger);
|
||||
this.setChannelVarsForStt = setChannelVarsForStt;
|
||||
this.digitCount = opts.digitCount || 0;
|
||||
this.numberRegEx = RegExp(`[0-9]{${this.digitCount}}`);
|
||||
|
||||
const {
|
||||
noSpeechTimeoutMs = 5000,
|
||||
decisionTimeoutMs = 15000,
|
||||
toneTimeoutMs = 20000,
|
||||
greetingCompletionTimeoutMs = 2000
|
||||
} = opts.timers || {};
|
||||
this.noSpeechTimeoutMs = noSpeechTimeoutMs;
|
||||
this.decisionTimeoutMs = decisionTimeoutMs;
|
||||
this.toneTimeoutMs = toneTimeoutMs;
|
||||
this.greetingCompletionTimeoutMs = greetingCompletionTimeoutMs;
|
||||
|
||||
this.beepDetected = false;
|
||||
}
|
||||
|
||||
startDecisionTimer() {
|
||||
this.decisionTimer = setTimeout(this._onDecisionTimeout.bind(this), this.decisionTimeoutMs);
|
||||
this.noSpeechTimer = setTimeout(this._onNoSpeechTimeout.bind(this), this.noSpeechTimeoutMs);
|
||||
this.startToneTimer();
|
||||
}
|
||||
stopDecisionTimer() {
|
||||
this.decisionTimer && clearTimeout(this.decisionTimer);
|
||||
}
|
||||
stopNoSpeechTimer() {
|
||||
this.noSpeechTimer && clearTimeout(this.noSpeechTimer);
|
||||
}
|
||||
startToneTimer() {
|
||||
this.toneTimer = setTimeout(this._onToneTimeout.bind(this), this.toneTimeoutMs);
|
||||
}
|
||||
startGreetingCompletionTimer() {
|
||||
this.greetingCompletionTimer = setTimeout(
|
||||
this._onGreetingCompletionTimeout.bind(this),
|
||||
this.beepDetected ? 1000 : this.greetingCompletionTimeoutMs);
|
||||
}
|
||||
stopGreetingCompletionTimer() {
|
||||
this.greetingCompletionTimer && clearTimeout(this.greetingCompletionTimer);
|
||||
}
|
||||
restartGreetingCompletionTimer() {
|
||||
this.stopGreetingCompletionTimer();
|
||||
this.startGreetingCompletionTimer();
|
||||
}
|
||||
stopToneTimer() {
|
||||
this.toneTimer && clearTimeout(this.toneTimer);
|
||||
}
|
||||
stopAllTimers() {
|
||||
this.stopDecisionTimer();
|
||||
this.stopNoSpeechTimer();
|
||||
this.stopToneTimer();
|
||||
this.stopGreetingCompletionTimer();
|
||||
}
|
||||
_onDecisionTimeout() {
|
||||
this.emit(this.decision = AmdEvents.DecisionTimeout);
|
||||
this.stopNoSpeechTimer();
|
||||
}
|
||||
_onToneTimeout() {
|
||||
this.emit(AmdEvents.ToneTimeout);
|
||||
}
|
||||
_onNoSpeechTimeout() {
|
||||
this.emit(this.decision = AmdEvents.NoSpeechDetected);
|
||||
this.stopDecisionTimer();
|
||||
}
|
||||
_onGreetingCompletionTimeout() {
|
||||
this.emit(AmdEvents.MachineStoppedSpeaking);
|
||||
}
|
||||
|
||||
evaluateTranscription(evt) {
|
||||
if (this.decision) {
|
||||
/* at this point we are only listening for the machine to stop speaking */
|
||||
if (this.decision === AmdEvents.MachineDetected) {
|
||||
this.restartGreetingCompletionTimer();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.stopNoSpeechTimer();
|
||||
|
||||
this.logger.debug({evt}, 'Amd:evaluateTranscription - raw');
|
||||
const t = this.normalizeTranscription(evt, this.vendor, this.language);
|
||||
const hints = voicemailHints[this.language] || [];
|
||||
|
||||
this.logger.debug({t}, 'Amd:evaluateTranscription - normalized');
|
||||
|
||||
if (Array.isArray(t.alternatives) && t.alternatives.length > 0) {
|
||||
const wordCount = t.alternatives[0].transcript.split(' ').length;
|
||||
const final = t.is_final;
|
||||
|
||||
const foundHint = hints.find((h) => t.alternatives[0].transcript.toLowerCase().includes(h.toLowerCase()));
|
||||
if (foundHint) {
|
||||
/* we detected a common voice mail greeting */
|
||||
this.logger.debug(`Amd:evaluateTranscription: found hint ${foundHint}`);
|
||||
this.emit(this.decision = AmdEvents.MachineDetected, {
|
||||
reason: 'hint',
|
||||
hint: foundHint,
|
||||
language: t.language_code
|
||||
});
|
||||
}
|
||||
else if (this.digitCount != 0 && this.numberRegEx.test(t.alternatives[0].transcript)) {
|
||||
/* a string of numbers is typically a machine */
|
||||
this.emit(this.decision = AmdEvents.MachineDetected, {
|
||||
reason: 'digit count',
|
||||
greeting: t.alternatives[0].transcript,
|
||||
language: t.language_code
|
||||
});
|
||||
}
|
||||
else if (final && wordCount < this.thresholdWordCount) {
|
||||
/* a short greeting is typically a human */
|
||||
this.emit(this.decision = AmdEvents.HumanDetected, {
|
||||
reason: 'short greeting',
|
||||
greeting: t.alternatives[0].transcript,
|
||||
language: t.language_code
|
||||
});
|
||||
}
|
||||
else if (wordCount >= this.thresholdWordCount) {
|
||||
/* a long greeting is typically a machine */
|
||||
this.emit(this.decision = AmdEvents.MachineDetected, {
|
||||
reason: 'long greeting',
|
||||
greeting: t.alternatives[0].transcript,
|
||||
language: t.language_code
|
||||
});
|
||||
}
|
||||
|
||||
if (this.decision) {
|
||||
this.stopDecisionTimer();
|
||||
|
||||
if (this.decision === AmdEvents.MachineDetected) {
|
||||
/* if we detected a machine, then wait for greeting to end */
|
||||
this.startGreetingCompletionTimer();
|
||||
}
|
||||
}
|
||||
return this.decision;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = (logger) => {
|
||||
const startTranscribing = async(cs, ep, task) => {
|
||||
const {vendor, language} = ep.amd;
|
||||
ep.startTranscription({
|
||||
vendor,
|
||||
locale: language,
|
||||
interim: true,
|
||||
bugname
|
||||
}).catch((err) => {
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
ep.amd = null;
|
||||
task.emit(AmdEvents.Error, err);
|
||||
logger.error(err, 'amd:_startTranscribing error');
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.STT_FAILURE,
|
||||
vendor: vendor,
|
||||
detail: err.message,
|
||||
target_sid: cs.callSid
|
||||
});
|
||||
}).catch((err) => logger.info({err}, 'Error generating alert for tts failure'));
|
||||
|
||||
};
|
||||
|
||||
const onEndOfUtterance = (cs, ep, task) => {
|
||||
logger.debug('amd:onEndOfUtterance');
|
||||
startTranscribing(cs, ep, task);
|
||||
};
|
||||
const onNoSpeechDetected = (cs, ep, task) => {
|
||||
logger.debug('amd:onNoSpeechDetected');
|
||||
ep.amd.stopAllTimers();
|
||||
task.emit(AmdEvents.NoSpeechDetected);
|
||||
};
|
||||
const onTranscription = (cs, ep, task, evt, fsEvent) => {
|
||||
if (fsEvent.getHeader('media-bugname') !== bugname) return;
|
||||
ep.amd?.evaluateTranscription(evt);
|
||||
};
|
||||
const onBeep = (cs, ep, task, evt, fsEvent) => {
|
||||
logger.debug({evt, fsEvent}, 'onBeep');
|
||||
const frequency = Math.floor(fsEvent.getHeader('Frequency'));
|
||||
const variance = Math.floor(fsEvent.getHeader('Frequency-variance'));
|
||||
task.emit('amd', {type: AmdEvents.ToneDetected, frequency, variance});
|
||||
if (ep.amd) {
|
||||
ep.amd.stopToneTimer();
|
||||
ep.amd.beepDetected = true;
|
||||
}
|
||||
ep.execute('avmd_stop').catch((err) => this.logger.info(err, 'Error stopping avmd'));
|
||||
};
|
||||
|
||||
const startAmd = async(cs, ep, task, opts) => {
|
||||
const amd = ep.amd = new Amd(logger, cs, opts);
|
||||
const {vendor, language} = amd;
|
||||
let sttCredentials = amd.sttCredentials;
|
||||
// hints from configuration might be too long for specific language and vendor that make transcribe freeswitch
|
||||
// modules cannot connect to the vendor. hints is used in next step to validate if the transcription
|
||||
// matchs voice mail hints.
|
||||
const hints = [];
|
||||
|
||||
if (vendor === 'nuance' && sttCredentials.client_id) {
|
||||
/* get nuance access token */
|
||||
const {getNuanceAccessToken} = amd;
|
||||
const {client_id, secret} = sttCredentials;
|
||||
const {access_token, servedFromCache} = await getNuanceAccessToken(client_id, secret, 'asr tts');
|
||||
logger.debug({client_id}, `Gather:exec - got nuance access token ${servedFromCache ? 'from cache' : ''}`);
|
||||
sttCredentials = {...sttCredentials, access_token};
|
||||
}
|
||||
else if (vendor == 'ibm' && sttCredentials.stt_api_key) {
|
||||
/* get ibm access token */
|
||||
const {getIbmAccessToken} = amd;
|
||||
const {stt_api_key, stt_region} = sttCredentials;
|
||||
const {access_token, servedFromCache} = await getIbmAccessToken(stt_api_key);
|
||||
logger.debug({stt_api_key}, `Gather:exec - got ibm access token ${servedFromCache ? 'from cache' : ''}`);
|
||||
sttCredentials = {...sttCredentials, access_token, stt_region};
|
||||
}
|
||||
|
||||
/* set stt options */
|
||||
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,
|
||||
});
|
||||
|
||||
await ep.set(sttOpts).catch((err) => logger.info(err, 'Error setting channel variables'));
|
||||
|
||||
amd.transcriptionHandler = onTranscription.bind(null, cs, ep, task);
|
||||
amd.EndOfUtteranceHandler = onEndOfUtterance.bind(null, cs, ep, task);
|
||||
amd.noSpeechHandler = onNoSpeechDetected.bind(null, cs, ep, task);
|
||||
|
||||
switch (vendor) {
|
||||
case 'google':
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, amd.transcriptionHandler);
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance, amd.EndOfUtteranceHandler);
|
||||
break;
|
||||
|
||||
case 'aws':
|
||||
case 'polly':
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, amd.transcriptionHandler);
|
||||
break;
|
||||
case 'microsoft':
|
||||
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription, amd.transcriptionHandler);
|
||||
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected, amd.noSpeechHandler);
|
||||
break;
|
||||
case 'nuance':
|
||||
ep.addCustomEventListener(NuanceTranscriptionEvents.Transcription, amd.transcriptionHandler);
|
||||
break;
|
||||
|
||||
case 'deepgram':
|
||||
ep.addCustomEventListener(DeepgramTranscriptionEvents.Transcription, amd.transcriptionHandler);
|
||||
break;
|
||||
|
||||
case 'soniox':
|
||||
amd.bugname = 'soniox_amd_transcribe';
|
||||
ep.addCustomEventListener(SonioxTranscriptionEvents.Transcription, amd.transcriptionHandler);
|
||||
break;
|
||||
|
||||
case 'ibm':
|
||||
ep.addCustomEventListener(IbmTranscriptionEvents.Transcription, amd.transcriptionHandler);
|
||||
break;
|
||||
|
||||
case 'nvidia':
|
||||
ep.addCustomEventListener(NvidiaTranscriptionEvents.Transcription, amd.transcriptionHandler);
|
||||
break;
|
||||
|
||||
case 'cobalt':
|
||||
ep.addCustomEventListener(CobaltTranscriptionEvents.Transcription, amd.transcriptionHandler);
|
||||
break;
|
||||
|
||||
default:
|
||||
if (vendor.startsWith('custom:')) {
|
||||
ep.addCustomEventListener(JambonzTranscriptionEvents.Transcription, amd.transcriptionHandler);
|
||||
break;
|
||||
}
|
||||
else {
|
||||
throw new Error(`Invalid vendor ${this.vendor}`);
|
||||
}
|
||||
}
|
||||
amd
|
||||
.on(AmdEvents.NoSpeechDetected, (evt) => {
|
||||
task.emit('amd', {type: AmdEvents.NoSpeechDetected, ...evt});
|
||||
try {
|
||||
stopAmd(ep, task);
|
||||
} catch (err) {
|
||||
logger.info({err}, 'Error stopping transcription');
|
||||
}
|
||||
})
|
||||
.on(AmdEvents.HumanDetected, (evt) => {
|
||||
task.emit('amd', {type: AmdEvents.HumanDetected, ...evt});
|
||||
try {
|
||||
stopAmd(ep, task);
|
||||
} catch (err) {
|
||||
logger.info({err}, 'Error stopping transcription');
|
||||
}
|
||||
})
|
||||
.on(AmdEvents.MachineDetected, (evt) => {
|
||||
task.emit('amd', {type: AmdEvents.MachineDetected, ...evt});
|
||||
})
|
||||
.on(AmdEvents.DecisionTimeout, (evt) => {
|
||||
task.emit('amd', {type: AmdEvents.DecisionTimeout, ...evt});
|
||||
try {
|
||||
stopAmd(ep, task);
|
||||
} catch (err) {
|
||||
logger.info({err}, 'Error stopping transcription');
|
||||
}
|
||||
})
|
||||
.on(AmdEvents.ToneTimeout, (evt) => {
|
||||
//task.emit('amd', {type: AmdEvents.ToneTimeout, ...evt});
|
||||
try {
|
||||
stopAmd(ep, task);
|
||||
} catch (err) {
|
||||
logger.info({err}, 'Error stopping avmd');
|
||||
}
|
||||
})
|
||||
.on(AmdEvents.MachineStoppedSpeaking, () => {
|
||||
task.emit('amd', {type: AmdEvents.MachineStoppedSpeaking});
|
||||
try {
|
||||
stopAmd(ep, task);
|
||||
} catch (err) {
|
||||
logger.info({err}, 'Error stopping transcription');
|
||||
}
|
||||
});
|
||||
|
||||
/* start transcribing, and also listening for beep */
|
||||
amd.startDecisionTimer();
|
||||
startTranscribing(cs, ep, task);
|
||||
|
||||
ep.addCustomEventListener(AvmdEvents.Beep, onBeep.bind(null, cs, ep, task));
|
||||
ep.execute('avmd_start').catch((err) => this.logger.info(err, 'Error starting avmd'));
|
||||
};
|
||||
|
||||
const stopAmd = (ep, task) => {
|
||||
let vendor;
|
||||
if (ep.amd) {
|
||||
vendor = ep.amd.vendor;
|
||||
ep.amd.stopAllTimers();
|
||||
|
||||
ep.removeListener(GoogleTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||
ep.removeListener(GoogleTranscriptionEvents.EndOfUtterance, ep.amd.EndOfUtteranceHandler);
|
||||
ep.removeListener(AwsTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||
ep.removeListener(AzureTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||
ep.removeListener(AzureTranscriptionEvents.NoSpeechDetected, ep.amd.noSpeechHandler);
|
||||
ep.removeListener(NuanceTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||
ep.removeListener(DeepgramTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||
ep.removeListener(SonioxTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||
ep.removeListener(IbmTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||
ep.removeListener(NvidiaTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||
ep.removeListener(JambonzTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||
|
||||
ep.amd = null;
|
||||
}
|
||||
|
||||
if (ep.connected) {
|
||||
ep.stopTranscription({vendor, bugname})
|
||||
.catch((err) => logger.info(err, 'stopAmd: Error stopping transcription'));
|
||||
task.emit('amd', {type: AmdEvents.Stopped});
|
||||
ep.execute('avmd_stop').catch((err) => this.logger.info(err, 'Error stopping avmd'));
|
||||
}
|
||||
ep.removeCustomEventListener(AvmdEvents.Beep);
|
||||
};
|
||||
|
||||
return {startAmd, stopAmd};
|
||||
};
|
||||
234
lib/utils/aws-sns-lifecycle.js
Normal file
234
lib/utils/aws-sns-lifecycle.js
Normal file
@@ -0,0 +1,234 @@
|
||||
const Emitter = require('events');
|
||||
const bent = require('bent');
|
||||
const assert = require('assert');
|
||||
const {
|
||||
AWS_REGION,
|
||||
AWS_SNS_PORT: PORT,
|
||||
AWS_SNS_TOPIC_ARN,
|
||||
AWS_SNS_PORT_MAX,
|
||||
} = require('../config');
|
||||
const {LifeCycleEvents} = require('./constants');
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
const getString = bent('string');
|
||||
const {
|
||||
SNSClient,
|
||||
SubscribeCommand,
|
||||
UnsubscribeCommand } = require('@aws-sdk/client-sns');
|
||||
const snsClient = new SNSClient({ region: AWS_REGION, apiVersion: '2010-03-31' });
|
||||
const {
|
||||
AutoScalingClient,
|
||||
DescribeAutoScalingGroupsCommand,
|
||||
CompleteLifecycleActionCommand } = require('@aws-sdk/client-auto-scaling');
|
||||
const autoScalingClient = new AutoScalingClient({ region: AWS_REGION, apiVersion: '2011-01-01' });
|
||||
const {Parser} = require('xml2js');
|
||||
const parser = new Parser();
|
||||
const {validatePayload} = require('verify-aws-sns-signature');
|
||||
|
||||
class SnsNotifier extends Emitter {
|
||||
constructor(logger) {
|
||||
super();
|
||||
|
||||
this.logger = logger;
|
||||
}
|
||||
_doListen(logger, app, port, resolve) {
|
||||
return app.listen(port, () => {
|
||||
this.snsEndpoint = `http://${this.publicIp}:${port}`;
|
||||
logger.info(`SNS lifecycle server listening on http://localhost:${port}`);
|
||||
resolve(app);
|
||||
});
|
||||
}
|
||||
|
||||
_handleErrors(logger, app, resolve, reject, e) {
|
||||
if (e.code === 'EADDRINUSE' &&
|
||||
AWS_SNS_PORT_MAX &&
|
||||
e.port < AWS_SNS_PORT_MAX) {
|
||||
|
||||
logger.info(`SNS lifecycle server failed to bind port on ${e.port}, will try next port`);
|
||||
const server = this._doListen(logger, app, ++e.port, resolve);
|
||||
server.on('error', this._handleErrors.bind(this, logger, app, resolve, reject));
|
||||
return;
|
||||
}
|
||||
reject(e);
|
||||
}
|
||||
|
||||
async _handlePost(req, res) {
|
||||
try {
|
||||
const parsedBody = JSON.parse(req.body);
|
||||
this.logger.info({headers: req.headers, body: parsedBody}, 'Received HTTP POST from AWS');
|
||||
if (!validatePayload(parsedBody)) {
|
||||
this.logger.info('incoming AWS SNS HTTP POST failed signature validation');
|
||||
return res.sendStatus(403);
|
||||
}
|
||||
this.logger.info('incoming HTTP POST passed validation');
|
||||
res.sendStatus(200);
|
||||
|
||||
switch (parsedBody.Type) {
|
||||
case 'SubscriptionConfirmation':
|
||||
const response = await getString(parsedBody.SubscribeURL);
|
||||
const result = await parser.parseStringPromise(response);
|
||||
this.subscriptionArn = result.ConfirmSubscriptionResponse.ConfirmSubscriptionResult[0].SubscriptionArn[0];
|
||||
this.subscriptionRequestId = result.ConfirmSubscriptionResponse.ResponseMetadata[0].RequestId[0];
|
||||
this.logger.info({
|
||||
subscriptionArn: this.subscriptionArn,
|
||||
subscriptionRequestId: this.subscriptionRequestId
|
||||
}, 'response from SNS SubscribeURL');
|
||||
const data = await this.describeInstance();
|
||||
|
||||
const group = data.AutoScalingGroups.find((group) =>
|
||||
group.Instances && group.Instances.some((instance) => instance.InstanceId === this.instanceId)
|
||||
);
|
||||
if (!group) {
|
||||
this.logger.error('Current instance not found in any Auto Scaling group', data);
|
||||
} else {
|
||||
const instance = group.Instances.find((instance) => instance.InstanceId === this.instanceId);
|
||||
this.lifecycleState = instance.LifecycleState;
|
||||
}
|
||||
|
||||
//this.lifecycleState = data.AutoScalingGroups[0].Instances[0].LifecycleState;
|
||||
this.emit('SubscriptionConfirmation', {publicIp: this.publicIp});
|
||||
break;
|
||||
|
||||
case 'Notification':
|
||||
if (parsedBody.Subject.startsWith('Auto Scaling: Lifecycle action \'TERMINATING\'')) {
|
||||
const msg = JSON.parse(parsedBody.Message);
|
||||
if (msg.EC2InstanceId === this.instanceId) {
|
||||
this.logger.info('SnsNotifier - begin scale-in operation');
|
||||
this.scaleInParams = {
|
||||
AutoScalingGroupName: msg.AutoScalingGroupName,
|
||||
LifecycleActionResult: 'CONTINUE',
|
||||
LifecycleActionToken: msg.LifecycleActionToken,
|
||||
LifecycleHookName: msg.LifecycleHookName
|
||||
};
|
||||
this.operationalState = LifeCycleEvents.ScaleIn;
|
||||
this.emit(LifeCycleEvents.ScaleIn);
|
||||
this.unsubscribe();
|
||||
}
|
||||
else {
|
||||
this.logger.info(`SnsNotifier - instance ${msg.EC2InstanceId} is scaling in (not us)`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
this.logger.info(`unhandled SNS Post Type: ${parsedBody.Type}`);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'Error processing SNS POST request');
|
||||
if (!res.headersSent) res.sendStatus(500);
|
||||
}
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
this.logger.info('SnsNotifier: retrieving instance data');
|
||||
this.instanceId = await getString('http://169.254.169.254/latest/meta-data/instance-id');
|
||||
this.publicIp = await getString('http://169.254.169.254/latest/meta-data/public-ipv4');
|
||||
this.logger.info({
|
||||
instanceId: this.instanceId,
|
||||
publicIp: this.publicIp
|
||||
}, 'retrieved AWS instance data');
|
||||
|
||||
// start listening
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.use(express.text());
|
||||
app.post('/', this._handlePost.bind(this));
|
||||
app.use((err, req, res, next) => {
|
||||
this.logger.error(err, 'burped error');
|
||||
res.status(err.status || 500).json({msg: err.message});
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = this._doListen(this.logger, app, PORT, resolve);
|
||||
server.on('error', this._handleErrors.bind(this, this.logger, app, resolve, reject));
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'Error retrieving AWS instance metadata');
|
||||
}
|
||||
}
|
||||
|
||||
async subscribe() {
|
||||
try {
|
||||
const params = {
|
||||
Protocol: 'http',
|
||||
TopicArn: AWS_SNS_TOPIC_ARN,
|
||||
Endpoint: this.snsEndpoint
|
||||
};
|
||||
const response = await snsClient.send(new SubscribeCommand(params));
|
||||
this.logger.info({response}, `response to SNS subscribe to ${AWS_SNS_TOPIC_ARN}`);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, `Error subscribing to SNS topic arn ${AWS_SNS_TOPIC_ARN}`);
|
||||
}
|
||||
}
|
||||
|
||||
async unsubscribe() {
|
||||
if (!this.subscriptionArn) throw new Error('SnsNotifier#unsubscribe called without an active subscription');
|
||||
try {
|
||||
const params = {
|
||||
SubscriptionArn: this.subscriptionArn
|
||||
};
|
||||
const response = await snsClient.send(new UnsubscribeCommand(params));
|
||||
this.logger.info({response}, `response to SNS unsubscribe to ${AWS_SNS_TOPIC_ARN}`);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, `Error unsubscribing to SNS topic arn ${AWS_SNS_TOPIC_ARN}`);
|
||||
}
|
||||
}
|
||||
|
||||
completeScaleIn() {
|
||||
assert(this.scaleInParams);
|
||||
autoScalingClient.send(new CompleteLifecycleActionCommand(this.scaleInParams))
|
||||
.then((data) => {
|
||||
return this.logger.info({data}, 'Successfully completed scale-in action');
|
||||
})
|
||||
.catch((err) => {
|
||||
this.logger.error({err}, 'Error completing scale-in');
|
||||
});
|
||||
}
|
||||
|
||||
describeInstance() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.instanceId) return reject('instance-id unknown');
|
||||
autoScalingClient.send(new DescribeAutoScalingGroupsCommand({
|
||||
InstanceIds: [this.instanceId]
|
||||
}))
|
||||
.then((data) => {
|
||||
this.logger.info({data}, 'SnsNotifier: describeInstance');
|
||||
return resolve(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
this.logger.error({err}, 'Error describing instances');
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = async function(logger) {
|
||||
const notifier = new SnsNotifier(logger);
|
||||
await notifier.init();
|
||||
await notifier.subscribe();
|
||||
|
||||
process.on('SIGHUP', async() => {
|
||||
try {
|
||||
const data = await notifier.describeInstance();
|
||||
const state = data.AutoScalingGroups[0].Instances[0].LifecycleState;
|
||||
if (state !== notifier.lifecycleState) {
|
||||
notifier.lifecycleState = state;
|
||||
switch (state) {
|
||||
case 'Standby':
|
||||
notifier.emit(LifeCycleEvents.StandbyEnter);
|
||||
break;
|
||||
case 'InService':
|
||||
notifier.emit(LifeCycleEvents.StandbyExit);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
return notifier;
|
||||
};
|
||||
221
lib/utils/background-task-manager.js
Normal file
221
lib/utils/background-task-manager.js
Normal file
@@ -0,0 +1,221 @@
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const makeTask = require('../tasks/make_task');
|
||||
const { JAMBONZ_RECORD_WS_BASE_URL, JAMBONZ_RECORD_WS_USERNAME, JAMBONZ_RECORD_WS_PASSWORD } = require('../config');
|
||||
const Emitter = require('events');
|
||||
|
||||
class BackgroundTaskManager extends Emitter {
|
||||
constructor({cs, logger, rootSpan}) {
|
||||
super();
|
||||
this.tasks = new Map();
|
||||
this.cs = cs;
|
||||
this.logger = logger;
|
||||
this.rootSpan = rootSpan;
|
||||
}
|
||||
|
||||
isTaskRunning(type) {
|
||||
return this.tasks.has(type);
|
||||
}
|
||||
|
||||
getTask(type) {
|
||||
if (this.tasks.has(type)) {
|
||||
return this.tasks.get(type);
|
||||
}
|
||||
}
|
||||
|
||||
count() {
|
||||
return this.tasks.size;
|
||||
}
|
||||
|
||||
async newTask(type, opts, sticky = false) {
|
||||
this.logger.info({opts}, `initiating Background task ${type}`);
|
||||
if (this.tasks.has(type)) {
|
||||
this.logger.info(`Background task ${type} is running, skipped`);
|
||||
return;
|
||||
}
|
||||
let task;
|
||||
switch (type) {
|
||||
case 'listen':
|
||||
task = await this._initListen(opts);
|
||||
break;
|
||||
case 'bargeIn':
|
||||
task = await this._initBargeIn(opts);
|
||||
break;
|
||||
case 'record':
|
||||
task = await this._initRecord();
|
||||
break;
|
||||
case 'transcribe':
|
||||
task = await this._initTranscribe(opts);
|
||||
break;
|
||||
case 'ttsStream':
|
||||
task = await this._initTtsStream(opts);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (task) {
|
||||
this.tasks.set(type, task);
|
||||
}
|
||||
if (task && sticky) task.sticky = true;
|
||||
return task;
|
||||
}
|
||||
|
||||
stop(type) {
|
||||
const task = this.getTask(type);
|
||||
if (task) {
|
||||
this.logger.info(`stopping background task: ${type}`);
|
||||
task.removeAllListeners();
|
||||
task.span.end();
|
||||
task.kill();
|
||||
// Remove task from managed List
|
||||
this.tasks.delete(type);
|
||||
}
|
||||
}
|
||||
|
||||
stopAll() {
|
||||
this.logger.debug('BackgroundTaskManager:stopAll');
|
||||
for (const key of this.tasks.keys()) {
|
||||
this.stop(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Initiate Listen
|
||||
async _initListen(opts, bugname = 'jambonz-background-listen', ignoreCustomerData = false, type = 'listen') {
|
||||
let task;
|
||||
try {
|
||||
const t = normalizeJambones(this.logger, [opts]);
|
||||
task = makeTask(this.logger, t[0]);
|
||||
task.bugname = bugname;
|
||||
task.ignoreCustomerData = ignoreCustomerData;
|
||||
const resources = await this.cs._evaluatePreconditions(task);
|
||||
const {span, ctx} = this.rootSpan.startChildSpan(`background-${type}:${task.summary}`);
|
||||
task.span = span;
|
||||
task.ctx = ctx;
|
||||
task.exec(this.cs, resources)
|
||||
.then(this._taskCompleted.bind(this, type, task))
|
||||
.catch(this._taskError.bind(this, type, task));
|
||||
} catch (err) {
|
||||
this.logger.info({err, opts}, `BackgroundTaskManager:_initListen - Error creating ${bugname} task`);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
// Initiate Gather
|
||||
async _initBargeIn(opts) {
|
||||
let task;
|
||||
try {
|
||||
const copy = JSON.parse(JSON.stringify(opts));
|
||||
const t = normalizeJambones(this.logger, [opts]);
|
||||
task = makeTask(this.logger, t[0]);
|
||||
task
|
||||
.once('dtmf', this._bargeInTaskCompleted.bind(this))
|
||||
.once('vad', this._bargeInTaskCompleted.bind(this))
|
||||
.once('transcription', this._bargeInTaskCompleted.bind(this))
|
||||
.once('timeout', this._bargeInTaskCompleted.bind(this));
|
||||
const resources = await this.cs._evaluatePreconditions(task);
|
||||
const {span, ctx} = this.rootSpan.startChildSpan(`background-bargeIn:${task.summary}`);
|
||||
task.span = span;
|
||||
task.ctx = ctx;
|
||||
task.bugname_prefix = 'background_bargeIn_';
|
||||
task.exec(this.cs, resources)
|
||||
.then(() => {
|
||||
this._taskCompleted('bargeIn', task);
|
||||
if (task.sticky && !this.cs.callGone && !this.cs._stopping) {
|
||||
this.logger.info('BackgroundTaskManager:_initBargeIn: restarting background bargeIn');
|
||||
this._bargeInHandled = false;
|
||||
this.newTask('bargeIn', copy, true);
|
||||
}
|
||||
return;
|
||||
})
|
||||
.catch(this._taskError.bind(this, 'bargeIn', task));
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'BackgroundTaskManager:_initGather - Error creating bargeIn task');
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
// Initiate Record
|
||||
async _initRecord() {
|
||||
if (this.cs.accountInfo.account.record_all_calls || this.cs.application.record_all_calls) {
|
||||
if (!JAMBONZ_RECORD_WS_BASE_URL || !this.cs.accountInfo.account.bucket_credential) {
|
||||
this.logger.error('_initRecord: invalid cfg - missing JAMBONZ_RECORD_WS_BASE_URL or bucket config');
|
||||
return undefined;
|
||||
}
|
||||
const listenOpts = {
|
||||
url: `${JAMBONZ_RECORD_WS_BASE_URL}/record/${this.cs.accountInfo.account.bucket_credential.vendor}`,
|
||||
disableBidirectionalAudio: true,
|
||||
mixType : 'stereo',
|
||||
passDtmf: true
|
||||
};
|
||||
if (JAMBONZ_RECORD_WS_USERNAME && JAMBONZ_RECORD_WS_PASSWORD) {
|
||||
listenOpts.wsAuth = {
|
||||
username: JAMBONZ_RECORD_WS_USERNAME,
|
||||
password: JAMBONZ_RECORD_WS_PASSWORD
|
||||
};
|
||||
}
|
||||
this.logger.debug({listenOpts}, '_initRecord: enabling listen');
|
||||
return await this._initListen({verb: 'listen', ...listenOpts}, 'jambonz-session-record', true, 'record');
|
||||
}
|
||||
}
|
||||
|
||||
// Initiate Transcribe
|
||||
async _initTranscribe(opts) {
|
||||
let task;
|
||||
try {
|
||||
const t = normalizeJambones(this.logger, [opts]);
|
||||
task = makeTask(this.logger, t[0]);
|
||||
const resources = await this.cs._evaluatePreconditions(task);
|
||||
const {span, ctx} = this.rootSpan.startChildSpan(`background-transcribe:${task.summary}`);
|
||||
task.span = span;
|
||||
task.ctx = ctx;
|
||||
task.bugname_prefix = 'background_transcribe_';
|
||||
task.exec(this.cs, resources)
|
||||
.then(this._taskCompleted.bind(this, 'transcribe', task))
|
||||
.catch(this._taskError.bind(this, 'transcribe', task));
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'BackgroundTaskManager:_initTranscribe - Error creating transcribe task');
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
// Initiate Tts Stream
|
||||
async _initTtsStream(opts) {
|
||||
let task;
|
||||
try {
|
||||
const t = normalizeJambones(this.logger, [opts]);
|
||||
task = makeTask(this.logger, t[0]);
|
||||
const resources = await this.cs._evaluatePreconditions(task);
|
||||
const {span, ctx} = this.rootSpan.startChildSpan(`background-ttsStream:${task.summary}`);
|
||||
task.span = span;
|
||||
task.ctx = ctx;
|
||||
task.exec(this.cs, resources)
|
||||
.then(this._taskCompleted.bind(this, 'ttsStream', task))
|
||||
.catch(this._taskError.bind(this, 'ttsStream', task));
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'BackgroundTaskManager:_initTtsStream - Error creating ttsStream task');
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
_taskCompleted(type, task) {
|
||||
this.logger.debug({type, task}, `BackgroundTaskManager:_taskCompleted: task completed, sticky: ${task.sticky}`);
|
||||
task.removeAllListeners();
|
||||
task.span.end();
|
||||
this.tasks.delete(type);
|
||||
}
|
||||
_taskError(type, task, error) {
|
||||
this.logger.info({type, task, error}, 'BackgroundTaskManager:_taskError: task Error');
|
||||
task.removeAllListeners();
|
||||
task.span.end();
|
||||
this.tasks.delete(type);
|
||||
}
|
||||
|
||||
_bargeInTaskCompleted(evt) {
|
||||
if (this._bargeInHandled) return;
|
||||
this._bargeInHandled = true;
|
||||
this.logger.debug({evt},
|
||||
'BackgroundTaskManager:_bargeInTaskCompleted on event from background bargeIn, emitting bargein-done event');
|
||||
this.emit('bargeIn-done', evt);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BackgroundTaskManager;
|
||||
122
lib/utils/base-requestor.js
Normal file
122
lib/utils/base-requestor.js
Normal file
@@ -0,0 +1,122 @@
|
||||
const assert = require('assert');
|
||||
const Emitter = require('events');
|
||||
const crypto = require('crypto');
|
||||
const parseUrl = require('parse-url');
|
||||
const timeSeries = require('@jambonz/time-series');
|
||||
const {NODE_ENV, JAMBONES_TIME_SERIES_HOST} = require('../config');
|
||||
let alerter ;
|
||||
|
||||
class BaseRequestor extends Emitter {
|
||||
constructor(logger, account_sid, hook, secret) {
|
||||
super();
|
||||
assert(typeof hook === 'object');
|
||||
|
||||
this.logger = logger;
|
||||
this.url = hook.url;
|
||||
|
||||
this.username = hook.username;
|
||||
this.password = hook.password;
|
||||
this.secret = secret;
|
||||
this.account_sid = account_sid;
|
||||
|
||||
const {stats} = require('../../').srf.locals;
|
||||
this.stats = stats;
|
||||
|
||||
const u = this._parsedUrl = parseUrl(this.url);
|
||||
if (u.port) this._baseUrl = `${u.protocol}://${u.resource}:${u.port}`;
|
||||
else this._baseUrl = `${u.protocol}://${u.resource}`;
|
||||
|
||||
if (!alerter) {
|
||||
alerter = timeSeries(logger, {
|
||||
host: JAMBONES_TIME_SERIES_HOST,
|
||||
commitSize: 50,
|
||||
commitInterval: 'test' === NODE_ENV ? 7 : 20
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get baseUrl() {
|
||||
return this._baseUrl;
|
||||
}
|
||||
|
||||
get Alerter() {
|
||||
return alerter;
|
||||
}
|
||||
|
||||
close() {
|
||||
/* subclass responsibility */
|
||||
}
|
||||
|
||||
_computeSignature(payload, timestamp, secret) {
|
||||
assert(secret);
|
||||
const data = `${timestamp}.${JSON.stringify(payload)}`;
|
||||
return crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(data, 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
_generateSigHeader(payload, secret) {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const signature = this._computeSignature(payload, timestamp, secret);
|
||||
const scheme = 'v1';
|
||||
return {
|
||||
'Jambonz-Signature': `t=${timestamp},${scheme}=${signature}`
|
||||
};
|
||||
}
|
||||
|
||||
_isAbsoluteUrl(u) {
|
||||
return typeof u === 'string' &&
|
||||
u.startsWith('https://') || u.startsWith('http://') ||
|
||||
u.startsWith('ws://') || u.startsWith('wss://');
|
||||
}
|
||||
_isRelativeUrl(u) {
|
||||
return typeof u === 'string' && u.startsWith('/');
|
||||
}
|
||||
_roundTrip(startAt) {
|
||||
const diff = process.hrtime(startAt);
|
||||
const time = diff[0] * 1e3 + diff[1] * 1e-6;
|
||||
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;
|
||||
92
lib/utils/call-tracer.js
Normal file
92
lib/utils/call-tracer.js
Normal file
@@ -0,0 +1,92 @@
|
||||
const {context, trace} = require('@opentelemetry/api');
|
||||
const {Dialog} = require('drachtio-srf');
|
||||
class RootSpan {
|
||||
constructor(callType, req) {
|
||||
const {srf} = require('../../');
|
||||
const tracer = srf.locals.otel.tracer;
|
||||
let callSid, accountSid, applicationSid, linkedSpanId;
|
||||
|
||||
if (req instanceof Dialog) {
|
||||
const dlg = req;
|
||||
callSid = dlg.callSid;
|
||||
linkedSpanId = dlg.linkedSpanId;
|
||||
}
|
||||
else if (req.srf) {
|
||||
callSid = req.locals.callSid;
|
||||
accountSid = req.get('X-Account-Sid'),
|
||||
applicationSid = req.locals.application_sid;
|
||||
}
|
||||
else {
|
||||
callSid = req.callSid;
|
||||
accountSid = req.accountSid;
|
||||
applicationSid = req.applicationSid;
|
||||
}
|
||||
this._span = tracer.startSpan(callType || 'incoming-call');
|
||||
if (req instanceof Dialog) {
|
||||
const dlg = req;
|
||||
this._span.setAttributes({
|
||||
linkedSpanId,
|
||||
callId: dlg.sip.callId
|
||||
});
|
||||
}
|
||||
else if (req.srf) {
|
||||
this._span.setAttributes({
|
||||
callSid,
|
||||
accountSid,
|
||||
applicationSid,
|
||||
callId: req.get('Call-ID'),
|
||||
externalCallId: req.get('X-CID')
|
||||
});
|
||||
}
|
||||
else {
|
||||
this._span.setAttributes({
|
||||
callSid,
|
||||
accountSid,
|
||||
applicationSid
|
||||
});
|
||||
}
|
||||
|
||||
this._ctx = trace.setSpan(context.active(), this._span);
|
||||
this.tracer = tracer;
|
||||
}
|
||||
|
||||
get context() {
|
||||
return this._ctx;
|
||||
}
|
||||
|
||||
get traceId() {
|
||||
return this._span.spanContext().traceId;
|
||||
}
|
||||
|
||||
get spanId() {
|
||||
return this._span.spanContext().spanId;
|
||||
}
|
||||
|
||||
get traceFlags() {
|
||||
return this._span.spanContext().traceFlags;
|
||||
}
|
||||
|
||||
getTracingPropagation(encoding) {
|
||||
// TODO: support encodings beyond b3 https://github.com/openzipkin/b3-propagation
|
||||
if (this._span && this.traceId !== '00000000000000000000000000000000') {
|
||||
return `${this.traceId}-${this.spanId}-1`;
|
||||
}
|
||||
}
|
||||
|
||||
setAttributes(attrs) {
|
||||
this._span.setAttributes(attrs);
|
||||
}
|
||||
|
||||
end() {
|
||||
this._span.end();
|
||||
}
|
||||
|
||||
startChildSpan(name, attributes) {
|
||||
const span = this.tracer.startSpan(name, attributes, this._ctx);
|
||||
const ctx = trace.setSpan(context.active(), span);
|
||||
return {span, ctx};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RootSpan;
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
{
|
||||
"TaskName": {
|
||||
"Answer": "answer",
|
||||
"Conference": "conference",
|
||||
"Config": "config",
|
||||
"Dequeue": "dequeue",
|
||||
"Dial": "dial",
|
||||
"Dialogflow": "dialogflow",
|
||||
"Dtmf": "dtmf",
|
||||
"Dub": "dub",
|
||||
"Enqueue": "enqueue",
|
||||
"Gather": "gather",
|
||||
"Hangup": "hangup",
|
||||
"Leave": "leave",
|
||||
"Lex": "lex",
|
||||
"Listen": "listen",
|
||||
"Llm": "llm",
|
||||
"Message": "message",
|
||||
"Pause": "pause",
|
||||
"Play": "play",
|
||||
"Rasa": "rasa",
|
||||
"Redirect": "redirect",
|
||||
"RestDial": "rest:dial",
|
||||
"SipDecline": "sip:decline",
|
||||
"SipRequest": "sip:request",
|
||||
"SipRefer": "sip:refer",
|
||||
"SipNotify": "sip:notify",
|
||||
"SipRedirect": "sip:redirect",
|
||||
"Say": "say",
|
||||
"SayLegacy": "say:legacy",
|
||||
"Stream": "stream",
|
||||
"Tag": "tag",
|
||||
"Transcribe": "transcribe"
|
||||
},
|
||||
"AllowedSipRecVerbs": ["answer", "config", "gather", "transcribe", "listen", "tag", "hangup", "sip:decline"],
|
||||
"AllowedConfirmSessionVerbs": ["config", "gather", "plays", "say", "tag"],
|
||||
"CallStatus": {
|
||||
"Trying": "trying",
|
||||
"Ringing": "ringing",
|
||||
@@ -28,24 +47,128 @@
|
||||
},
|
||||
"CallDirection": {
|
||||
"Inbound": "inbound",
|
||||
"Outbound": "outbound"
|
||||
"Outbound": "outbound",
|
||||
"None": "none"
|
||||
},
|
||||
"ListenStatus": {
|
||||
"Pause": "pause",
|
||||
"Silence": "silence",
|
||||
"Resume": "resume"
|
||||
},
|
||||
"TranscribeStatus": {
|
||||
"Pause": "pause",
|
||||
"Silence": "silence",
|
||||
"Resume": "resume"
|
||||
},
|
||||
"TaskPreconditions": {
|
||||
"None": "none",
|
||||
"Endpoint": "endpoint",
|
||||
"StableCall": "stable-call",
|
||||
"UnansweredCall": "unanswered-call"
|
||||
},
|
||||
"TranscriptionEvents": {
|
||||
"AvmdEvents": {
|
||||
"Beep": "avmd::beep"
|
||||
},
|
||||
"GoogleTranscriptionEvents": {
|
||||
"Transcription": "google_transcribe::transcription",
|
||||
"EndOfUtterance": "google_transcribe::end_of_utterance",
|
||||
"NoAudioDetected": "google_transcribe::no_audio_detected",
|
||||
"MaxDurationExceeded": "google_transcribe::max_duration_exceeded"
|
||||
"MaxDurationExceeded": "google_transcribe::max_duration_exceeded",
|
||||
"VadDetected": "google_transcribe::vad_detected"
|
||||
},
|
||||
"NuanceTranscriptionEvents": {
|
||||
"Transcription": "nuance_transcribe::transcription",
|
||||
"StartOfSpeech": "nuance_transcribe::start_of_speech",
|
||||
"TranscriptionComplete": "nuance_transcribe::end_of_transcription",
|
||||
"Error": "nuance_transcribe::error",
|
||||
"VadDetected": "nuance_transcribe::vad_detected"
|
||||
},
|
||||
"NvidiaTranscriptionEvents": {
|
||||
"Transcription": "nvidia_transcribe::transcription",
|
||||
"StartOfSpeech": "nvidia_transcribe::start_of_speech",
|
||||
"TranscriptionComplete": "nvidia_transcribe::end_of_transcription",
|
||||
"Error": "nvidia_transcribe::error",
|
||||
"VadDetected": "nvidia_transcribe::vad_detected"
|
||||
},
|
||||
"DeepgramTranscriptionEvents": {
|
||||
"Transcription": "deepgram_transcribe::transcription",
|
||||
"ConnectFailure": "deepgram_transcribe::connect_failed",
|
||||
"Connect": "deepgram_transcribe::connect"
|
||||
},
|
||||
"SonioxTranscriptionEvents": {
|
||||
"Transcription": "soniox_transcribe::transcription",
|
||||
"Error": "soniox_transcribe::error"
|
||||
},
|
||||
"VerbioTranscriptionEvents": {
|
||||
"Transcription": "verbio_transcribe::transcription",
|
||||
"Error": "verbio_transcribe::error"
|
||||
},
|
||||
"CobaltTranscriptionEvents": {
|
||||
"Transcription": "cobalt_speech::transcription",
|
||||
"CompileContext": "cobalt_speech::compile_context_response",
|
||||
"Error": "cobalt_speech::error"
|
||||
},
|
||||
"IbmTranscriptionEvents": {
|
||||
"Transcription": "ibm_transcribe::transcription",
|
||||
"ConnectFailure": "ibm_transcribe::connect_failed",
|
||||
"Connect": "ibm_transcribe::connect",
|
||||
"Error": "ibm_transcribe::error"
|
||||
},
|
||||
"AwsTranscriptionEvents": {
|
||||
"Transcription": "aws_transcribe::transcription",
|
||||
"EndOfTranscript": "aws_transcribe::end_of_transcript",
|
||||
"NoAudioDetected": "aws_transcribe::no_audio_detected",
|
||||
"MaxDurationExceeded": "aws_transcribe::max_duration_exceeded",
|
||||
"VadDetected": "aws_transcribe::vad_detected"
|
||||
},
|
||||
"AzureTranscriptionEvents": {
|
||||
"Transcription": "azure_transcribe::transcription",
|
||||
"StartOfUtterance": "azure_transcribe::start_of_utterance",
|
||||
"EndOfUtterance": "azure_transcribe::end_of_utterance",
|
||||
"NoSpeechDetected": "azure_transcribe::no_speech_detected",
|
||||
"VadDetected": "azure_transcribe::vad_detected"
|
||||
},
|
||||
"SpeechmaticsTranscriptionEvents": {
|
||||
"Transcription": "speechmatics_transcribe::transcription",
|
||||
"Translation": "speechmatics_transcribe::translation",
|
||||
"Info": "speechmatics_transcribe::info",
|
||||
"RecognitionStarted": "speechmatics_transcribe::recognition_started",
|
||||
"ConnectFailure": "speechmatics_transcribe::connect_failed",
|
||||
"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",
|
||||
"Connect": "jambonz_transcribe::connect",
|
||||
"Error": "jambonz_transcribe::error"
|
||||
},
|
||||
"AssemblyAiTranscriptionEvents": {
|
||||
"Transcription": "assemblyai_transcribe::transcription",
|
||||
"Error": "assemblyai_transcribe::error",
|
||||
"ConnectFailure": "assemblyai_transcribe::connect_failed",
|
||||
"Connect": "assemblyai_transcribe::connect"
|
||||
},
|
||||
"VoxistTranscriptionEvents": {
|
||||
"Transcription": "voxist_transcribe::transcription",
|
||||
"Error": "voxist_transcribe::error",
|
||||
"ConnectFailure": "voxist_transcribe::connect_failed",
|
||||
"Connect": "voxist_transcribe::connect"
|
||||
},
|
||||
"VadDetection": {
|
||||
"Detection": "vad_detect:detection"
|
||||
},
|
||||
"ListenEvents": {
|
||||
"Connect": "mod_audio_fork::connect",
|
||||
@@ -59,5 +182,148 @@
|
||||
"BufferOverrun": "mod_audio_fork::buffer_overrun",
|
||||
"JsonMessage": "mod_audio_fork::json"
|
||||
},
|
||||
"MAX_SIMRINGS": 10
|
||||
"LifeCycleEvents" : {
|
||||
"ScaleIn": "scale-in",
|
||||
"StandbyEnter": "standby-enter",
|
||||
"StandbyExit": "standby-exit"
|
||||
},
|
||||
"LlmEvents_OpenAI": {
|
||||
"Error": "error",
|
||||
"Connect": "openai_s2s::connect",
|
||||
"ConnectFailure": "openai_s2s::connect_failed",
|
||||
"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",
|
||||
"ConnectFailure": "elevenlabs_s2s::connect_failed",
|
||||
"Disconnect": "elevenlabs_s2s::disconnect",
|
||||
"ServerEvent": "elevenlabs_s2s::server_event"
|
||||
},
|
||||
"LlmEvents_VoiceAgent": {
|
||||
"Error": "error",
|
||||
"Connect": "voice_agent_s2s::connect",
|
||||
"ConnectFailure": "voice_agent_s2s::connect_failed",
|
||||
"Disconnect": "voice_agent_s2s::disconnect",
|
||||
"ServerEvent": "voice_agent_s2s::server_event"
|
||||
},
|
||||
"LlmEvents_Ultravox": {
|
||||
"Error": "error",
|
||||
"Connect": "ultravox_s2s::connect",
|
||||
"ConnectFailure": "ultravox_s2s::connect_failed",
|
||||
"Disconnect": "ultravox_s2s::disconnect",
|
||||
"ServerEvent": "ultravox_s2s::server_event"
|
||||
},
|
||||
"QueueResults": {
|
||||
"Bridged": "bridged",
|
||||
"Error": "error",
|
||||
"Wait": "hangup",
|
||||
"Leave": "leave"
|
||||
},
|
||||
"DequeueResults": {
|
||||
"Bridged": "bridged",
|
||||
"Error": "error",
|
||||
"Hangup": "hangup",
|
||||
"Timeout": "timeout"
|
||||
},
|
||||
"KillReason": {
|
||||
"Hangup": "hangup",
|
||||
"Replaced": "replaced",
|
||||
"MediaTimeout": "media_timeout"
|
||||
},
|
||||
"HookMsgTypes": [
|
||||
"session:new",
|
||||
"session:reconnect",
|
||||
"session:redirect",
|
||||
"session:adulting",
|
||||
"call:status",
|
||||
"queue:status",
|
||||
"dial:confirm",
|
||||
"verb:hook",
|
||||
"verb:status",
|
||||
"llm:event",
|
||||
"llm:tool-call",
|
||||
"tts:tokens-result",
|
||||
"tts:streaming-event",
|
||||
"jambonz:error"
|
||||
],
|
||||
"RecordState": {
|
||||
"RecordingOn": "recording_on",
|
||||
"RecordingOff": "recording_off",
|
||||
"RecordingPaused": "recording_paused"
|
||||
},
|
||||
"AmdEvents": {
|
||||
"NoSpeechDetected": "amd_no_speech_detected",
|
||||
"HumanDetected": "amd_human_detected",
|
||||
"MachineDetected": "amd_machine_detected",
|
||||
"MachineStoppedSpeaking": "amd_machine_stopped_speaking",
|
||||
"Error": "amd_error",
|
||||
"DecisionTimeout": "amd_decision_timeout",
|
||||
"ToneDetected": "amd_tone_detected",
|
||||
"ToneTimeout": "amd_tone_timeout",
|
||||
"Stopped": "amd_stopped"
|
||||
},
|
||||
"MediaPath": {
|
||||
"NoMedia": "no-media",
|
||||
"PartialMedia": "partial-media",
|
||||
"FullMedia": "full-media"
|
||||
},
|
||||
"DeepgramTtsStreamingEvents": {
|
||||
"Empty": "deepgram_tts_streaming::empty",
|
||||
"ConnectFailure": "deepgram_tts_streaming::connect_failed",
|
||||
"Connect": "deepgram_tts_streaming::connect"
|
||||
},
|
||||
"CartesiaTtsStreamingEvents": {
|
||||
"Empty": "cartesia_tts_streaming::empty",
|
||||
"ConnectFailure": "cartesia_tts_streaming::connect_failed",
|
||||
"Connect": "cartesia_tts_streaming::connect"
|
||||
},
|
||||
"ElevenlabsTtsStreamingEvents": {
|
||||
"Empty": "elevenlabs_tts_streaming::empty",
|
||||
"ConnectFailure": "elevenlabs_tts_streaming::connect_failed",
|
||||
"Connect": "elevenlabs_tts_streaming::connect"
|
||||
},
|
||||
"RimelabsTtsStreamingEvents": {
|
||||
"Empty": "rimelabs_tts_streaming::empty",
|
||||
"ConnectFailure": "rimelabs_tts_streaming::connect_failed",
|
||||
"Connect": "rimelabs_tts_streaming::connect"
|
||||
},
|
||||
"CustomTtsStreamingEvents": {
|
||||
"Empty": "custom_tts_streaming::empty",
|
||||
"ConnectFailure": "custom_tts_streaming::connect_failed",
|
||||
"Connect": "custom_tts_streaming::connect"
|
||||
},
|
||||
"TtsStreamingEvents": {
|
||||
"Empty": "tts_streaming::empty",
|
||||
"Pause": "tts_streaming::pause",
|
||||
"Resume": "tts_streaming::resume",
|
||||
"ConnectFailure": "tts_streaming::connect_failed"
|
||||
},
|
||||
"TtsStreamingConnectionStatus": {
|
||||
"NotConnected": "not_connected",
|
||||
"Connected": "connected",
|
||||
"Connecting": "connecting",
|
||||
"Failed": "failed"
|
||||
},
|
||||
"MAX_SIMRINGS": 10,
|
||||
"BONG_TONE": "tone_stream://v=-7;%(100,0,941.0,1477.0);v=-7;>=2;+=.1;%(1400,0,350,440)",
|
||||
"FS_UUID_SET_NAME": "fsUUIDs",
|
||||
"SystemState" : {
|
||||
"Online": "ONLINE",
|
||||
"Offline": "OFFLINE",
|
||||
"GracefulShutdownInProgress":"SHUTDOWN_IN_PROGRESS"
|
||||
},
|
||||
"FEATURE_SERVER" : "feature-server",
|
||||
"WS_CLOSE_CODES": {
|
||||
"NormalClosure": 1000,
|
||||
"GoingAway": 1001
|
||||
}
|
||||
}
|
||||
|
||||
57
lib/utils/cron-jobs.js
Normal file
57
lib/utils/cron-jobs.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const {execSync} = require('child_process');
|
||||
const {
|
||||
JAMBONES_FREESWITCH,
|
||||
NODE_ENV,
|
||||
JAMBONES_FREESWITCH_MAX_CALL_DURATION_MINS,
|
||||
} = require('../config');
|
||||
const now = Date.now();
|
||||
const fsInventory = JAMBONES_FREESWITCH
|
||||
.split(',')
|
||||
.map((fs) => {
|
||||
const arr = /^([^:]*):([^:]*):([^:]*)(?::([^:]*))?/.exec(fs);
|
||||
const opts = {address: arr[1], port: arr[2], secret: arr[3]};
|
||||
if (arr.length > 4) opts.advertisedAddress = arr[4];
|
||||
if (NODE_ENV === 'test') opts.listenAddress = '0.0.0.0';
|
||||
return opts;
|
||||
});
|
||||
|
||||
const clearChannels = () => {
|
||||
const {logger} = require('../..');
|
||||
const pwd = fsInventory[0].secret;
|
||||
const maxDurationMins = JAMBONES_FREESWITCH_MAX_CALL_DURATION_MINS;
|
||||
|
||||
const calls = execSync(`/usr/local/freeswitch/bin/fs_cli -p ${pwd} -x "show calls"`, {encoding: 'utf8'})
|
||||
.split('\n')
|
||||
.filter((line) => line.match(/^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{8}/))
|
||||
.map((line) => {
|
||||
const arr = line.split(',');
|
||||
const dt = new Date(arr[2]);
|
||||
const duration = (now - dt.getTime()) / 1000;
|
||||
return {
|
||||
uuid: arr[0],
|
||||
time: arr[2],
|
||||
duration
|
||||
};
|
||||
})
|
||||
.filter((c) => c.duration > 60 * maxDurationMins);
|
||||
|
||||
if (calls.length > 0) {
|
||||
logger.debug(`clearChannels: clearing ${calls.length} old calls longer than ${maxDurationMins} mins`);
|
||||
for (const call of calls) {
|
||||
const cmd = `/usr/local/freeswitch/bin/fs_cli -p ${pwd} -x "uuid_kill ${call.uuid}"`;
|
||||
const out = execSync(cmd, {encoding: 'utf8'});
|
||||
logger.debug({out}, 'clearChannels: command output');
|
||||
}
|
||||
}
|
||||
return calls.length;
|
||||
};
|
||||
|
||||
const clearFiles = () => {
|
||||
//const {logger} = require('../..');
|
||||
/*const out = */ execSync('find /tmp -name "*.mp3" -mtime +2 -exec rm {} \\;');
|
||||
//logger.debug({out}, 'clearFiles: command output');
|
||||
};
|
||||
|
||||
|
||||
module.exports = {clearChannels, clearFiles};
|
||||
|
||||
255
lib/utils/db-utils.js
Normal file
255
lib/utils/db-utils.js
Normal file
@@ -0,0 +1,255 @@
|
||||
const {decrypt} = require('./encrypt-decrypt');
|
||||
|
||||
const sqlAccountDetails = `SELECT *
|
||||
FROM accounts account
|
||||
WHERE account.account_sid = ?`;
|
||||
const sqlSpeechCredentialsForAccount = `SELECT *
|
||||
FROM speech_credentials
|
||||
WHERE account_sid = ? OR (account_sid is NULL AND service_provider_sid =
|
||||
(SELECT service_provider_sid from accounts where account_sid = ?))`;
|
||||
const sqlQueryAccountCarrierByName = `SELECT voip_carrier_sid
|
||||
FROM voip_carriers vc
|
||||
WHERE vc.account_sid = ?
|
||||
AND vc.name = ?`;
|
||||
const sqlQuerySPCarrierByName = `SELECT voip_carrier_sid
|
||||
FROM voip_carriers vc
|
||||
WHERE vc.account_sid IS NULL
|
||||
AND vc.service_provider_sid =
|
||||
(SELECT service_provider_sid from accounts where account_sid = ?)
|
||||
AND vc.name = ?`;
|
||||
const sqlQueryAccountPhoneNumber = `SELECT voip_carrier_sid
|
||||
FROM phone_numbers pn
|
||||
WHERE pn.account_sid = ?
|
||||
AND pn.number = ?`;
|
||||
const sqlQuerySPPhoneNumber = `SELECT voip_carrier_sid
|
||||
FROM phone_numbers pn
|
||||
WHERE pn.account_sid IS NULL
|
||||
AND pn.service_provider_sid =
|
||||
(SELECT service_provider_sid from accounts where account_sid = ?)
|
||||
AND pn.number = ?`;
|
||||
const sqlQueryGoogleCustomVoices = `SELECT *
|
||||
FROM google_custom_voices
|
||||
WHERE google_custom_voice_sid = ?`;
|
||||
|
||||
const speechMapper = (cred) => {
|
||||
const {credential, ...obj} = cred;
|
||||
try {
|
||||
if ('google' === obj.vendor) {
|
||||
obj.service_key = decrypt(credential);
|
||||
}
|
||||
else if ('aws' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.access_key_id = o.access_key_id;
|
||||
obj.secret_access_key = o.secret_access_key;
|
||||
obj.role_arn = o.role_arn;
|
||||
obj.aws_region = o.aws_region;
|
||||
}
|
||||
else if ('microsoft' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.region = o.region;
|
||||
obj.use_custom_stt = o.use_custom_stt;
|
||||
obj.custom_stt_endpoint = o.custom_stt_endpoint;
|
||||
obj.custom_stt_endpoint_url = o.custom_stt_endpoint_url;
|
||||
obj.use_custom_tts = o.use_custom_tts;
|
||||
obj.custom_tts_endpoint = o.custom_tts_endpoint;
|
||||
obj.custom_tts_endpoint_url = o.custom_tts_endpoint_url;
|
||||
}
|
||||
else if ('wellsaid' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
}
|
||||
else if ('nuance' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.client_id = o.client_id;
|
||||
obj.secret = o.secret;
|
||||
obj.nuance_tts_uri = o.nuance_tts_uri;
|
||||
obj.nuance_stt_uri = o.nuance_stt_uri;
|
||||
}
|
||||
else if ('ibm' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.tts_api_key = o.tts_api_key;
|
||||
obj.tts_region = o.tts_region;
|
||||
obj.stt_api_key = o.stt_api_key;
|
||||
obj.stt_region = o.stt_region;
|
||||
}
|
||||
else if ('deepgram' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
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 ('soniox' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
}
|
||||
else if ('nvidia' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.riva_server_uri = o.riva_server_uri;
|
||||
}
|
||||
else if ('cobalt' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.cobalt_server_uri = o.cobalt_server_uri;
|
||||
}
|
||||
else if ('elevenlabs' === 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 ('playht' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.user_id = o.user_id;
|
||||
obj.voice_engine = o.voice_engine;
|
||||
obj.options = o.options;
|
||||
}
|
||||
else if ('cartesia' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.model_id = o.model_id;
|
||||
obj.embedding = o.embedding;
|
||||
obj.options = o.options;
|
||||
}
|
||||
else if ('rimelabs' === 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;
|
||||
}
|
||||
else if ('voxist' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
}
|
||||
else if ('whisper' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.model_id = o.model_id;
|
||||
}
|
||||
else if ('verbio' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.client_id = o.client_id;
|
||||
obj.client_secret = o.client_secret;
|
||||
obj.engine_version = o.engine_version;
|
||||
}
|
||||
else if ('speechmatics' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
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;
|
||||
obj.custom_stt_url = o.custom_stt_url;
|
||||
obj.custom_tts_url = o.custom_tts_url;
|
||||
obj.custom_tts_streaming_url = o.custom_tts_streaming_url;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
const bucketCredentialDecrypt = (account) => {
|
||||
const { bucket_credential } = account.account;
|
||||
if (!bucket_credential || bucket_credential.vendor) return;
|
||||
account.account.bucket_credential = JSON.parse(decrypt(bucket_credential));
|
||||
};
|
||||
|
||||
module.exports = (logger, srf) => {
|
||||
const {pool} = srf.locals.dbHelpers;
|
||||
const pp = pool.promise();
|
||||
|
||||
const lookupAccountDetails = async(account_sid) => {
|
||||
|
||||
const [r] = await pp.query({sql: sqlAccountDetails, nestTables: true}, [account_sid]);
|
||||
if (0 === r.length) throw new Error(`invalid accountSid: ${account_sid}`);
|
||||
const [r2] = await pp.query(sqlSpeechCredentialsForAccount, [account_sid, account_sid]);
|
||||
const speech = r2.map(speechMapper);
|
||||
|
||||
const account = r[0];
|
||||
bucketCredentialDecrypt(account);
|
||||
|
||||
return {
|
||||
...account,
|
||||
speech
|
||||
};
|
||||
};
|
||||
|
||||
const updateSpeechCredentialLastUsed = async(speech_credential_sid) => {
|
||||
if (!speech_credential_sid) return;
|
||||
const pp = pool.promise();
|
||||
const sql = 'UPDATE speech_credentials SET last_used = NOW() WHERE speech_credential_sid = ?';
|
||||
try {
|
||||
await pp.execute(sql, [speech_credential_sid]);
|
||||
} catch (err) {
|
||||
logger.error({err}, `Error updating last_used for speech_credential_sid ${speech_credential_sid}`);
|
||||
}
|
||||
};
|
||||
|
||||
const lookupCarrier = async(account_sid, carrierName) => {
|
||||
const pp = pool.promise();
|
||||
try {
|
||||
const [r] = await pp.query(sqlQueryAccountCarrierByName, [account_sid, carrierName]);
|
||||
if (r.length) return r[0].voip_carrier_sid;
|
||||
const [r2] = await pp.query(sqlQuerySPCarrierByName, [account_sid, carrierName]);
|
||||
if (r2.length) return r2[0].voip_carrier_sid;
|
||||
} catch (err) {
|
||||
logger.error({err}, `lookupCarrier: Error ${account_sid}:${carrierName}`);
|
||||
}
|
||||
};
|
||||
|
||||
const lookupCarrierByPhoneNumber = async(account_sid, phoneNumber) => {
|
||||
const pp = pool.promise();
|
||||
try {
|
||||
const [r] = await pp.query(sqlQueryAccountPhoneNumber, [account_sid, phoneNumber]);
|
||||
if (r.length) return r[0].voip_carrier_sid;
|
||||
const [r2] = await pp.query(sqlQuerySPPhoneNumber, [account_sid, phoneNumber]);
|
||||
if (r2.length) return r2[0].voip_carrier_sid;
|
||||
} catch (err) {
|
||||
logger.error({err}, `lookupPhoneNumber: Error ${account_sid}:${phoneNumber}`);
|
||||
}
|
||||
};
|
||||
|
||||
const lookupGoogleCustomVoice = async(google_custom_voice_sid) => {
|
||||
const pp = pool.promise();
|
||||
try {
|
||||
const [r] = await pp.query(sqlQueryGoogleCustomVoices, [google_custom_voice_sid]);
|
||||
return r;
|
||||
|
||||
} catch (err) {
|
||||
logger.error({err}, `lookupGoogleCustomVoices: Error ${google_custom_voice_sid}`);
|
||||
}
|
||||
};
|
||||
|
||||
const lookupVoipCarrierBySid = async(sid) => {
|
||||
const pp = pool.promise();
|
||||
try {
|
||||
const [r] = await pp.query('SELECT * FROM voip_carriers WHERE voip_carrier_sid = ?', [sid]);
|
||||
return r;
|
||||
|
||||
} catch (err) {
|
||||
logger.error({err}, `lookupVoipCarrierBySid: Error ${sid}`);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
lookupAccountDetails,
|
||||
updateSpeechCredentialLastUsed,
|
||||
lookupCarrier,
|
||||
lookupCarrierByPhoneNumber,
|
||||
lookupGoogleCustomVoice,
|
||||
lookupVoipCarrierBySid
|
||||
};
|
||||
};
|
||||
36
lib/utils/encrypt-decrypt.js
Normal file
36
lib/utils/encrypt-decrypt.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const crypto = require('crypto');
|
||||
const {LEGACY_CRYPTO, ENCRYPTION_SECRET, JWT_SECRET} = require('../config');
|
||||
const algorithm = LEGACY_CRYPTO ? 'aes-256-ctr' : 'aes-256-cbc';
|
||||
const iv = crypto.randomBytes(16);
|
||||
const secretKey = crypto.createHash('sha256')
|
||||
.update(ENCRYPTION_SECRET || JWT_SECRET)
|
||||
.digest('base64')
|
||||
.substring(0, 32);
|
||||
|
||||
const encrypt = (text) => {
|
||||
const cipher = crypto.createCipheriv(algorithm, secretKey, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(text), cipher.final()]);
|
||||
const data = {
|
||||
iv: iv.toString('hex'),
|
||||
content: encrypted.toString('hex')
|
||||
};
|
||||
return JSON.stringify(data);
|
||||
};
|
||||
|
||||
const decrypt = (data) => {
|
||||
let hash;
|
||||
try {
|
||||
hash = JSON.parse(data);
|
||||
} catch (err) {
|
||||
console.log(`failed to parse json string ${data}`);
|
||||
throw err;
|
||||
}
|
||||
const decipher = crypto.createDecipheriv(algorithm, secretKey, Buffer.from(hash.iv, 'hex'));
|
||||
const decrypted = Buffer.concat([decipher.update(Buffer.from(hash.content, 'hex')), decipher.final()]);
|
||||
return decrypted.toString();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
encrypt,
|
||||
decrypt
|
||||
};
|
||||
33
lib/utils/error.js
Normal file
33
lib/utils/error.js
Normal file
@@ -0,0 +1,33 @@
|
||||
class NonFatalTaskError extends Error {
|
||||
constructor(msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
|
||||
class SpeechCredentialError extends NonFatalTaskError {
|
||||
constructor(msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
|
||||
class PlayFileNotFoundError extends NonFatalTaskError {
|
||||
constructor(url) {
|
||||
super('File not found');
|
||||
this.url = url;
|
||||
}
|
||||
}
|
||||
|
||||
class HTTPResponseError extends Error {
|
||||
constructor(statusCode) {
|
||||
super('Unexpected HTTP Response');
|
||||
delete this.stack;
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SpeechCredentialError,
|
||||
NonFatalTaskError,
|
||||
PlayFileNotFoundError,
|
||||
HTTPResponseError
|
||||
};
|
||||
5
lib/utils/helpers.js
Normal file
5
lib/utils/helpers.js
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
const sleepFor = (ms) => new Promise((resolve) => setTimeout(() => resolve(), ms));
|
||||
module.exports = {
|
||||
sleepFor
|
||||
};
|
||||
46
lib/utils/http-listener.js
Normal file
46
lib/utils/http-listener.js
Normal file
@@ -0,0 +1,46 @@
|
||||
|
||||
const express = require('express');
|
||||
const httpRoutes = require('../http-routes');
|
||||
const {PORT, HTTP_PORT_MAX} = require('../config');
|
||||
|
||||
const doListen = (logger, app, port, resolve) => {
|
||||
const server = app.listen(port, () => {
|
||||
const {srf} = app.locals;
|
||||
srf.locals.serviceUrl = `http://${srf.locals.ipv4}:${port}`;
|
||||
logger.info(`listening for HTTP requests on port ${port}, serviceUrl is ${srf.locals.serviceUrl}`);
|
||||
resolve({server, app});
|
||||
});
|
||||
return server;
|
||||
};
|
||||
const handleErrors = (logger, app, resolve, reject, e) => {
|
||||
if (e.code === 'EADDRINUSE' &&
|
||||
HTTP_PORT_MAX &&
|
||||
e.port < HTTP_PORT_MAX) {
|
||||
|
||||
logger.info(`HTTP server failed to bind port on ${e.port}, will try next port`);
|
||||
const server = doListen(logger, app, ++e.port, resolve);
|
||||
server.on('error', handleErrors.bind(null, logger, app, resolve, reject));
|
||||
return;
|
||||
}
|
||||
logger.info({err: e, port: PORT}, 'httpListener error');
|
||||
reject(e);
|
||||
};
|
||||
|
||||
const createHttpListener = (logger, srf) => {
|
||||
const app = express();
|
||||
app.locals = {...app.locals, logger, srf};
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.use('/', httpRoutes);
|
||||
app.use((err, _req, res, _next) => {
|
||||
logger.error(err, 'burped error');
|
||||
res.status(err.status || 500).json({msg: err.message});
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = doListen(logger, app, PORT, resolve);
|
||||
server.on('error', handleErrors.bind(null, logger, app, resolve, reject));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
module.exports = createHttpListener;
|
||||
276
lib/utils/http-requestor.js
Normal file
276
lib/utils/http-requestor.js
Normal file
@@ -0,0 +1,276 @@
|
||||
const {request, getGlobalDispatcher, setGlobalDispatcher, Dispatcher, ProxyAgent, Client, Pool} = require('undici');
|
||||
const parseUrl = require('parse-url');
|
||||
const assert = require('assert');
|
||||
const BaseRequestor = require('./base-requestor');
|
||||
const {HookMsgTypes} = require('./constants.json');
|
||||
const snakeCaseKeys = require('./snakecase-keys');
|
||||
const pools = new Map();
|
||||
const {
|
||||
HTTP_POOL,
|
||||
HTTP_POOLSIZE,
|
||||
HTTP_PIPELINING,
|
||||
HTTP_TIMEOUT,
|
||||
HTTP_PROXY_IP,
|
||||
HTTP_PROXY_PORT,
|
||||
HTTP_PROXY_PROTOCOL,
|
||||
NODE_ENV,
|
||||
HTTP_USER_AGENT_HEADER,
|
||||
} = require('../config');
|
||||
const {HTTPResponseError} = require('./error');
|
||||
|
||||
const toBase64 = (str) => Buffer.from(str || '', 'utf8').toString('base64');
|
||||
|
||||
function basicAuth(username, password) {
|
||||
if (!username || !password) return {};
|
||||
const creds = `${username}:${password || ''}`;
|
||||
const header = `Basic ${toBase64(creds)}`;
|
||||
return {Authorization: header};
|
||||
}
|
||||
|
||||
const defaultDispatcher = HTTP_PROXY_IP ?
|
||||
new ProxyAgent(`${HTTP_PROXY_PROTOCOL}://${HTTP_PROXY_IP}${HTTP_PROXY_PORT ? `:${HTTP_PROXY_PORT}` : ''}`) :
|
||||
getGlobalDispatcher();
|
||||
|
||||
setGlobalDispatcher(new class extends Dispatcher {
|
||||
dispatch(options, handler) {
|
||||
return defaultDispatcher.dispatch(options, handler);
|
||||
}
|
||||
}());
|
||||
|
||||
class HttpRequestor extends BaseRequestor {
|
||||
constructor(logger, account_sid, hook, secret) {
|
||||
super(logger, account_sid, hook, secret);
|
||||
|
||||
this.method = hook.method || 'POST';
|
||||
this.authHeader = basicAuth(hook.username, hook.password);
|
||||
this.backoffMs = 500;
|
||||
|
||||
assert(this._isAbsoluteUrl(this.url));
|
||||
assert(['GET', 'POST'].includes(this.method));
|
||||
|
||||
const u = this._parsedUrl = parseUrl(this.url);
|
||||
this._protocol = u.protocol;
|
||||
this._resource = u.resource;
|
||||
this._port = u.port;
|
||||
this._search = u.search;
|
||||
this._usePools = HTTP_POOL && parseInt(HTTP_POOL);
|
||||
|
||||
if (this._usePools) {
|
||||
if (pools.has(this.baseUrl)) {
|
||||
this.client = pools.get(this.baseUrl);
|
||||
}
|
||||
else {
|
||||
const connections = HTTP_POOLSIZE ? parseInt(HTTP_POOLSIZE) : 10;
|
||||
const pipelining = HTTP_PIPELINING ? parseInt(HTTP_PIPELINING) : 1;
|
||||
const pool = this.client = new Pool(this.baseUrl, {
|
||||
connections,
|
||||
pipelining
|
||||
});
|
||||
pools.set(this.baseUrl, pool);
|
||||
this.logger.debug(`HttpRequestor:created pool for ${this.baseUrl}`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (u.port) this.client = new Client(`${u.protocol}://${u.resource}:${u.port}`);
|
||||
else this.client = new Client(`${u.protocol}://${u.resource}`);
|
||||
}
|
||||
|
||||
if (NODE_ENV == 'test' && process.env.JAMBONES_HTTP_PROXY_IP) {
|
||||
const defDispatcher =
|
||||
new ProxyAgent(`${process.env.JAMBONES_HTTP_PROXY_PROTOCOL}://${process.env.JAMBONES_HTTP_PROXY_IP}${
|
||||
process.env.JAMBONES_HTTP_PROXY_PORT ? `:${process.env.JAMBONES_HTTP_PROXY_PORT}` : ''}`);
|
||||
|
||||
setGlobalDispatcher(new class extends Dispatcher {
|
||||
dispatch(options, handler) {
|
||||
return defDispatcher.dispatch(options, handler);
|
||||
}
|
||||
}());
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this._usePools && !this.client?.closed) this.client.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an HTTP request.
|
||||
* All requests use json bodies.
|
||||
* All requests expect a 200 statusCode on success
|
||||
* @param {object|string} hook - may be a absolute or relative url, or an object
|
||||
* @param {string} [hook.url] - an absolute or relative url
|
||||
* @param {string} [hook.method] - 'GET' or 'POST'
|
||||
* @param {string} [hook.username] - if basic auth is protecting the endpoint
|
||||
* @param {string} [hook.password] - if basic auth is protecting the endpoint
|
||||
* @param {object} [params] - request parameters
|
||||
*/
|
||||
async request(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', 'env_vars', 'args']) : null;
|
||||
const url = hook.url || hook;
|
||||
const method = hook.method || 'POST';
|
||||
let buf = '';
|
||||
httpHeaders = {
|
||||
...httpHeaders,
|
||||
...(HTTP_USER_AGENT_HEADER && {'user-agent' : HTTP_USER_AGENT_HEADER})
|
||||
};
|
||||
|
||||
assert.ok(url, 'HttpRequestor:request url was not provided');
|
||||
assert.ok, (['GET', 'POST'].includes(method), `HttpRequestor:request method must be 'GET' or 'POST' not ${method}`);
|
||||
const startAt = process.hrtime();
|
||||
|
||||
/* if we have an absolute url, and it is ws then do a websocket connection */
|
||||
if (this._isAbsoluteUrl(url) && url.startsWith('ws')) {
|
||||
const WsRequestor = require('./ws-requestor');
|
||||
this.logger.debug({hook}, 'HttpRequestor: switching to websocket connection');
|
||||
const h = typeof hook === 'object' ? hook : {url: hook};
|
||||
const requestor = new WsRequestor(this.logger, this.account_sid, h, this.secret);
|
||||
if (type === 'session:redirect') {
|
||||
this.close();
|
||||
this.emit('handover', requestor);
|
||||
}
|
||||
return requestor.request('session:new', hook, params, httpHeaders, 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 {
|
||||
if (parsedUrl.resource === this._resource &&
|
||||
parsedUrl.port === this._port &&
|
||||
parsedUrl.protocol === this._protocol) {
|
||||
client = this.client;
|
||||
path = parsedUrl.pathname;
|
||||
query = parsedUrl.query;
|
||||
}
|
||||
else {
|
||||
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,
|
||||
...this.authHeader,
|
||||
...httpHeaders,
|
||||
...('POST' === method && {'Content-Type': 'application/json'})
|
||||
};
|
||||
|
||||
const requestOptions = {
|
||||
path,
|
||||
query,
|
||||
method,
|
||||
headers: hdrs,
|
||||
...('POST' === method && {body: JSON.stringify(payload)}),
|
||||
timeout: HTTP_TIMEOUT,
|
||||
followRedirects: false
|
||||
};
|
||||
|
||||
// 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) {
|
||||
this.logger.info({baseUrl: this.baseUrl, url},
|
||||
`web callback returned unexpected status code ${err.statusCode}`);
|
||||
}
|
||||
else {
|
||||
this.logger.error({err, baseUrl: this.baseUrl, url},
|
||||
'web callback returned unexpected error');
|
||||
}
|
||||
let opts = {account_sid: this.account_sid};
|
||||
if (err.code === 'ECONNREFUSED') {
|
||||
opts = {...opts, alert_type: this.Alerter.AlertType.WEBHOOK_CONNECTION_FAILURE, url};
|
||||
}
|
||||
else if (err.name === 'StatusError') {
|
||||
opts = {...opts, alert_type: this.Alerter.AlertType.WEBHOOK_STATUS_FAILURE, url, status: err.statusCode};
|
||||
}
|
||||
else {
|
||||
opts = {...opts, alert_type: this.Alerter.AlertType.WEBHOOK_CONNECTION_FAILURE, url, detail: err.message};
|
||||
}
|
||||
this.Alerter.writeAlerts(opts).catch((err) => this.logger.info({err, opts}, 'Error writing alert'));
|
||||
|
||||
if (newClient) newClient.close();
|
||||
throw err;
|
||||
}
|
||||
const rtt = this._roundTrip(startAt);
|
||||
if (buf) this.stats.histogram('app.hook.response_time', rtt, ['hook_type:app']);
|
||||
|
||||
if (buf && (Array.isArray(buf) || type == 'llm:tool-call')) {
|
||||
this.logger.info({response: buf}, `HttpRequestor:request ${method} ${url} succeeded in ${rtt}ms`);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = HttpRequestor;
|
||||
@@ -1,13 +1,78 @@
|
||||
const Mrf = require('drachtio-fsmrf');
|
||||
const ip = require('ip');
|
||||
const localIp = ip.address();
|
||||
const PORT = process.env.HTTP_PORT || 3000;
|
||||
const os = require('os');
|
||||
const {
|
||||
JAMBONES_MYSQL_HOST,
|
||||
JAMBONES_MYSQL_USER,
|
||||
JAMBONES_MYSQL_PASSWORD,
|
||||
JAMBONES_MYSQL_DATABASE,
|
||||
JAMBONES_MYSQL_CONNECTION_LIMIT,
|
||||
JAMBONES_MYSQL_PORT,
|
||||
JAMBONES_FREESWITCH,
|
||||
SMPP_URL,
|
||||
JAMBONES_TIME_SERIES_HOST,
|
||||
JAMBONES_ESL_LISTEN_ADDRESS,
|
||||
PORT,
|
||||
HTTP_IP,
|
||||
NODE_ENV,
|
||||
} = require('../config');
|
||||
const Registrar = require('@jambonz/mw-registrar');
|
||||
const assert = require('assert');
|
||||
|
||||
function installSrfLocals(srf, logger) {
|
||||
function getLocalIp() {
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const interfaceName in interfaces) {
|
||||
const interface = interfaces[interfaceName];
|
||||
for (const iface of interface) {
|
||||
if (iface.family === 'IPv4' && !iface.internal) {
|
||||
return iface.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '127.0.0.1'; // Fallback to localhost if no suitable interface found
|
||||
}
|
||||
|
||||
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(`esl::ready connected to freeswitch at ${ms.address}`);
|
||||
}
|
||||
wrapper.connects = 1;
|
||||
wrapper.active = true;
|
||||
});
|
||||
|
||||
ms.on('channel::open', (evt) => {
|
||||
logger.debug({evt}, `mediaserver ${ms.address} added endpoint`);
|
||||
});
|
||||
ms.on('channel::close', (evt) => {
|
||||
logger.debug({evt}, `mediaserver ${ms.address} removed endpoint`);
|
||||
});
|
||||
}
|
||||
|
||||
function installSrfLocals(srf, logger, {
|
||||
onFreeswitchConnect = () => {},
|
||||
onFreeswitchDisconnect = () => {}
|
||||
}) {
|
||||
logger.debug('installing srf locals');
|
||||
assert(!srf.locals.dbHelpers);
|
||||
const {getSBC, getSrf} = require('./sbc-pinger')(logger);
|
||||
const StatsCollector = require('jambonz-stats-collector');
|
||||
const {tracer} = srf.locals.otel;
|
||||
const {getSBC, lifecycleEmitter} = require('./sbc-pinger')(logger);
|
||||
const StatsCollector = require('@jambonz/stats-collector');
|
||||
const stats = srf.locals.stats = new StatsCollector(logger);
|
||||
|
||||
// freeswitch connections (typically we connect to only one)
|
||||
@@ -16,12 +81,19 @@ function installSrfLocals(srf, logger) {
|
||||
let idxStart = 0;
|
||||
|
||||
(async function() {
|
||||
const fsInventory = process.env.JAMBONES_FREESWITCH
|
||||
const fsInventory = JAMBONES_FREESWITCH
|
||||
.split(',')
|
||||
.map((fs) => {
|
||||
const arr = /^(.*):(.*):(.*)/.exec(fs);
|
||||
assert.ok(arr, `Invalid syntax JAMBONES_FREESWITCH: ${process.env.JAMBONES_FREESWITCH}`);
|
||||
return {address: arr[1], port: arr[2], secret: arr[3]};
|
||||
const arr = /^([^:]*):([^:]*):([^:]*)(?::([^:]*))?/.exec(fs);
|
||||
assert.ok(arr, `Invalid syntax JAMBONES_FREESWITCH: ${JAMBONES_FREESWITCH}`);
|
||||
const opts = {address: arr[1], port: arr[2], secret: arr[3]};
|
||||
if (arr.length > 4) opts.advertisedAddress = arr[4];
|
||||
/* NB: originally for testing only, but for now all jambonz deployments
|
||||
have freeswitch installed locally alongside this app
|
||||
*/
|
||||
if (NODE_ENV === 'test') opts.listenAddress = '0.0.0.0';
|
||||
else if (JAMBONES_ESL_LISTEN_ADDRESS) opts.listenAddress = JAMBONES_ESL_LISTEN_ADDRESS;
|
||||
return opts;
|
||||
});
|
||||
logger.info({fsInventory}, 'freeswitch inventory');
|
||||
|
||||
@@ -30,35 +102,31 @@ function installSrfLocals(srf, logger) {
|
||||
mediaservers.push(val);
|
||||
try {
|
||||
const ms = await mrf.connect(fs);
|
||||
Object.assign(val, {ms, active: true, connects: 1});
|
||||
logger.info(`connected to freeswitch at ${fs.address}`);
|
||||
|
||||
ms.conn
|
||||
.on('esl::end', () => {
|
||||
val.active = false;
|
||||
logger.info(`lost connection to freeswitch at ${fs.address}`);
|
||||
})
|
||||
.on('esl::ready', () => {
|
||||
if (val.connects > 0) {
|
||||
logger.info(`connected to freeswitch at ${fs.address}`);
|
||||
}
|
||||
val.connects = 1;
|
||||
val.active = true;
|
||||
});
|
||||
initMS(logger, val, ms, {
|
||||
onFreeswitchConnect,
|
||||
onFreeswitchDisconnect
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
logger.info(`failed connecting to freeswitch at ${fs.address}, will retry shortly`);
|
||||
logger.info({err}, `failed connecting to freeswitch at ${fs.address}, will retry shortly: ${err.message}`);
|
||||
}
|
||||
}
|
||||
// retry to connect to any that were initially offline
|
||||
setInterval(async() => {
|
||||
for (const val of mediaservers) {
|
||||
if (val.connect === 0) {
|
||||
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);
|
||||
val.ms = ms;
|
||||
initMS(logger, val, ms, {
|
||||
onFreeswitchConnect,
|
||||
onFreeswitchDisconnect
|
||||
});
|
||||
} catch (err) {
|
||||
logger.info(`failed connecting to freeswitch at ${val.opts.address}, will retry shortly`);
|
||||
logger.info({err}, `failed connecting to freeswitch at ${val.opts.address}, will retry shortly`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,13 +134,16 @@ function installSrfLocals(srf, logger) {
|
||||
|
||||
// if we have a single freeswitch (as is typical) report stats periodically
|
||||
if (mediaservers.length === 1) {
|
||||
const ms = mediaservers[0].ms;
|
||||
srf.locals.mediaservers = [mediaservers[0].ms];
|
||||
setInterval(() => {
|
||||
try {
|
||||
stats.gauge('fs.media.channels.in_use', ms.currentSessions);
|
||||
stats.gauge('fs.media.channels.free', ms.maxSessions - ms.currentSessions);
|
||||
stats.gauge('fs.media.calls_per_second', ms.cps);
|
||||
stats.gauge('fs.media.cpu_idle', ms.cpuIdle);
|
||||
if (mediaservers[0].ms && mediaservers[0].active) {
|
||||
const ms = mediaservers[0].ms;
|
||||
stats.gauge('fs.media.channels.in_use', ms.currentSessions);
|
||||
stats.gauge('fs.media.channels.free', ms.maxSessions - ms.currentSessions);
|
||||
stats.gauge('fs.media.calls_per_second', ms.cps);
|
||||
stats.gauge('fs.media.cpu_idle', ms.cpuIdle);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger.info(err, 'Error sending media server metrics');
|
||||
@@ -91,44 +162,142 @@ function installSrfLocals(srf, logger) {
|
||||
}
|
||||
|
||||
const {
|
||||
pool,
|
||||
lookupAppByPhoneNumber,
|
||||
lookupAppByRegex,
|
||||
lookupAppBySid,
|
||||
lookupAppByRealm
|
||||
} = require('jambonz-db-helpers')({
|
||||
host: process.env.JAMBONES_MYSQL_HOST,
|
||||
user: process.env.JAMBONES_MYSQL_USER,
|
||||
password: process.env.JAMBONES_MYSQL_PASSWORD,
|
||||
database: process.env.JAMBONES_MYSQL_DATABASE,
|
||||
connectionLimit: process.env.JAMBONES_MYSQL_CONNECTION_LIMIT || 10
|
||||
lookupAppByRealm,
|
||||
lookupAppByTeamsTenant,
|
||||
lookupTeamsByAccount,
|
||||
lookupAccountBySid,
|
||||
lookupAccountCapacitiesBySid,
|
||||
lookupSmppGateways,
|
||||
lookupClientByAccountAndUsername,
|
||||
lookupSystemInformation
|
||||
} = require('@jambonz/db-helpers')({
|
||||
host: JAMBONES_MYSQL_HOST,
|
||||
user: JAMBONES_MYSQL_USER,
|
||||
port: JAMBONES_MYSQL_PORT || 3306,
|
||||
password: JAMBONES_MYSQL_PASSWORD,
|
||||
database: JAMBONES_MYSQL_DATABASE,
|
||||
connectionLimit: JAMBONES_MYSQL_CONNECTION_LIMIT || 10
|
||||
}, logger);
|
||||
const {
|
||||
client,
|
||||
updateCallStatus,
|
||||
retrieveCall,
|
||||
listCalls,
|
||||
deleteCall
|
||||
} = require('jambonz-realtimedb-helpers')({
|
||||
host: process.env.JAMBONES_REDIS_HOST,
|
||||
port: process.env.JAMBONES_REDIS_PORT || 6379
|
||||
}, logger);
|
||||
deleteCall,
|
||||
createHash,
|
||||
retrieveHash,
|
||||
deleteKey,
|
||||
addKey,
|
||||
retrieveKey,
|
||||
retrieveSet,
|
||||
addToSet,
|
||||
removeFromSet,
|
||||
monitorSet,
|
||||
pushBack,
|
||||
popFront,
|
||||
removeFromList,
|
||||
getListPosition,
|
||||
lengthOfList,
|
||||
addToSortedSet,
|
||||
retrieveFromSortedSet,
|
||||
retrieveByPatternSortedSet,
|
||||
sortedSetLength,
|
||||
sortedSetPositionByPattern,
|
||||
} = require('@jambonz/realtimedb-helpers')({}, logger, tracer);
|
||||
const registrar = new Registrar(logger, client);
|
||||
const {
|
||||
synthAudio,
|
||||
addFileToCache,
|
||||
getNuanceAccessToken,
|
||||
getIbmAccessToken,
|
||||
getAwsAuthToken,
|
||||
getVerbioAccessToken
|
||||
} = require('@jambonz/speech-utils')({}, logger);
|
||||
const {
|
||||
writeAlerts,
|
||||
AlertType,
|
||||
writeSystemAlerts
|
||||
} = require('@jambonz/time-series')(logger, {
|
||||
host: JAMBONES_TIME_SERIES_HOST,
|
||||
commitSize: 50,
|
||||
commitInterval: 'test' === NODE_ENV ? 7 : 20
|
||||
});
|
||||
|
||||
Object.assign(srf.locals, {
|
||||
let localIp;
|
||||
try {
|
||||
// Either use the configured IP address or discover it
|
||||
localIp = HTTP_IP || getLocalIp();
|
||||
} catch (err) {
|
||||
logger.error({err}, 'installSrfLocals - error detecting local ipv4 address');
|
||||
}
|
||||
|
||||
srf.locals = {...srf.locals,
|
||||
dbHelpers: {
|
||||
client,
|
||||
registrar,
|
||||
pool,
|
||||
lookupAppByPhoneNumber,
|
||||
lookupAppByRegex,
|
||||
lookupAppBySid,
|
||||
lookupAppByRealm,
|
||||
lookupAppByTeamsTenant,
|
||||
lookupTeamsByAccount,
|
||||
lookupAccountBySid,
|
||||
lookupAccountCapacitiesBySid,
|
||||
lookupSmppGateways,
|
||||
lookupClientByAccountAndUsername,
|
||||
lookupSystemInformation,
|
||||
updateCallStatus,
|
||||
retrieveCall,
|
||||
listCalls,
|
||||
deleteCall
|
||||
deleteCall,
|
||||
synthAudio,
|
||||
getAwsAuthToken,
|
||||
addFileToCache,
|
||||
createHash,
|
||||
retrieveHash,
|
||||
deleteKey,
|
||||
addKey,
|
||||
retrieveKey,
|
||||
retrieveSet,
|
||||
addToSet,
|
||||
removeFromSet,
|
||||
monitorSet,
|
||||
pushBack,
|
||||
popFront,
|
||||
removeFromList,
|
||||
lengthOfList,
|
||||
getListPosition,
|
||||
getNuanceAccessToken,
|
||||
getIbmAccessToken,
|
||||
addToSortedSet,
|
||||
retrieveFromSortedSet,
|
||||
retrieveByPatternSortedSet,
|
||||
sortedSetLength,
|
||||
sortedSetPositionByPattern,
|
||||
getVerbioAccessToken
|
||||
},
|
||||
parentLogger: logger,
|
||||
ipv4: localIp,
|
||||
serviceUrl: `http://${localIp}:${PORT}`,
|
||||
getSBC,
|
||||
getSrf,
|
||||
getSmpp: () => {
|
||||
return SMPP_URL;
|
||||
},
|
||||
lifecycleEmitter,
|
||||
getFreeswitch,
|
||||
stats: stats
|
||||
});
|
||||
stats: stats,
|
||||
writeAlerts,
|
||||
AlertType,
|
||||
writeSystemAlerts
|
||||
};
|
||||
|
||||
if (localIp) {
|
||||
srf.locals.ipv4 = localIp;
|
||||
srf.locals.serviceUrl = `http://${localIp}:${PORT}`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = installSrfLocals;
|
||||
|
||||
103
lib/utils/llm-mcp.js
Normal file
103
lib/utils/llm-mcp.js
Normal 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;
|
||||
|
||||
32
lib/utils/network.js
Normal file
32
lib/utils/network.js
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
/**
|
||||
* Parses a list of hostport entries and selects the first one that matches the specified protocol,
|
||||
* excluding any entries with the localhost IP address ('127.0.0.1').
|
||||
*
|
||||
* Each hostport entry should be in the format: 'protocol/ip:port'
|
||||
*
|
||||
* @param {Object} logger - A logging object with a 'debug' method for logging debug messages.
|
||||
* @param {string} hostport - A comma-separated string containing hostport entries.
|
||||
* @param {string} protocol - The protocol to match (e.g., 'udp', 'tcp').
|
||||
* @returns {Array} An array containing:
|
||||
* 0: protocol
|
||||
* 1: ip address
|
||||
* 2: port
|
||||
*/
|
||||
const selectHostPort = (logger, hostport, protocol) => {
|
||||
logger.debug(`selectHostPort: ${hostport}, ${protocol}`);
|
||||
const sel = hostport
|
||||
.split(',')
|
||||
.map((hp) => {
|
||||
const arr = /(.*)\/(.*):(.*)/.exec(hp);
|
||||
return [arr[1], arr[2], arr[3]];
|
||||
})
|
||||
.filter((hp) => {
|
||||
return hp[0] === protocol && hp[1] !== '127.0.0.1';
|
||||
});
|
||||
return sel[0];
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
selectHostPort
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
function normalizeJambones(logger, obj) {
|
||||
if (!Array.isArray(obj)) throw new Error('malformed jambonz payload: must be array');
|
||||
const document = [];
|
||||
for (const tdata of obj) {
|
||||
if (typeof tdata !== 'object') throw new Error('malformed jambonz payload: must be array of objects');
|
||||
if ('verb' in tdata) {
|
||||
// {verb: 'say', text: 'foo..bar'..}
|
||||
const name = tdata.verb;
|
||||
const o = {};
|
||||
Object.keys(tdata)
|
||||
.filter((k) => k !== 'verb')
|
||||
.forEach((k) => o[k] = tdata[k]);
|
||||
const o2 = {};
|
||||
o2[name] = o;
|
||||
document.push(o2);
|
||||
}
|
||||
else if (Object.keys(tdata).length === 1) {
|
||||
// {'say': {..}}
|
||||
document.push(tdata);
|
||||
}
|
||||
else {
|
||||
logger.info(tdata, 'malformed jambonz payload: missing verb property');
|
||||
throw new Error('malformed jambonz payload: missing verb property');
|
||||
}
|
||||
}
|
||||
logger.debug({document}, `normalizeJambones: returning document with ${document.length} tasks`);
|
||||
return document;
|
||||
}
|
||||
|
||||
module.exports = normalizeJambones;
|
||||
|
||||
18
lib/utils/parse-decibels.js
Normal file
18
lib/utils/parse-decibels.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const parseDecibels = (db) => {
|
||||
if (!db) return 0;
|
||||
if (typeof db === 'number') {
|
||||
return db;
|
||||
}
|
||||
else if (typeof db === 'string') {
|
||||
const match = db.match(/([+-]?\d+(\.\d+)?)\s*db/i);
|
||||
if (match) {
|
||||
return Math.trunc(parseFloat(match[1]));
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = parseDecibels;
|
||||
@@ -1,38 +1,55 @@
|
||||
const Emitter = require('events');
|
||||
const {CallStatus} = require('./constants');
|
||||
const {CallStatus, MediaPath} = require('./constants');
|
||||
const SipError = require('drachtio-srf').SipError;
|
||||
const {TaskPreconditions, CallDirection} = require('../utils/constants');
|
||||
const CallInfo = require('../session/call-info');
|
||||
const assert = require('assert');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const makeTask = require('../tasks/make_task');
|
||||
const ConfirmCallSession = require('../session/confirm-call-session');
|
||||
const selectSbc = require('./select-sbc');
|
||||
const Registrar = require('jambonz-mw-registrar');
|
||||
const registrar = new Registrar({
|
||||
host: process.env.JAMBONES_REDIS_HOST,
|
||||
port: process.env.JAMBONES_REDIS_PORT || 6379
|
||||
});
|
||||
|
||||
const AdultingCallSession = require('../session/adulting-call-session');
|
||||
const deepcopy = require('deepcopy');
|
||||
const moment = require('moment');
|
||||
const uuidv4 = require('uuid/v4');
|
||||
const stripCodecs = require('./strip-ancillary-codecs');
|
||||
const RootSpan = require('./call-tracer');
|
||||
const crypto = require('crypto');
|
||||
const HttpRequestor = require('./http-requestor');
|
||||
const WsRequestor = require('./ws-requestor');
|
||||
const {makeOpusFirst, removeVideoSdp} = 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');
|
||||
|
||||
class SingleDialer extends Emitter {
|
||||
constructor({logger, sbcAddress, target, opts, application, callInfo}) {
|
||||
constructor({logger, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan, dialTask,
|
||||
onHoldMusic}) {
|
||||
super();
|
||||
assert(target.type);
|
||||
|
||||
this.logger = logger;
|
||||
this.target = target;
|
||||
this.from = target.from || {};
|
||||
this.sbcAddress = sbcAddress;
|
||||
this.opts = opts;
|
||||
this.application = application;
|
||||
this.confirmHook = target.confirmHook;
|
||||
this.rootSpan = rootSpan;
|
||||
this.startSpan = startSpan;
|
||||
|
||||
this.bindings = logger.bindings();
|
||||
|
||||
this.parentCallInfo = callInfo;
|
||||
this.accountInfo = accountInfo;
|
||||
|
||||
this.callGone = false;
|
||||
|
||||
this.callSid = uuidv4();
|
||||
this.callSid = crypto.randomUUID();
|
||||
this.dialTask = dialTask;
|
||||
this.onHoldMusic = onHoldMusic;
|
||||
|
||||
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
|
||||
}
|
||||
@@ -41,6 +58,10 @@ class SingleDialer extends Emitter {
|
||||
return this.callInfo.callStatus;
|
||||
}
|
||||
|
||||
get applicationSid() {
|
||||
return this.application?.application_sid || this.callInfo?.applicationSid;
|
||||
}
|
||||
|
||||
/**
|
||||
* can be used for all http requests within this session
|
||||
*/
|
||||
@@ -58,28 +79,51 @@ class SingleDialer extends Emitter {
|
||||
}
|
||||
|
||||
async exec(srf, ms, opts) {
|
||||
let uri, to;
|
||||
opts = opts || {};
|
||||
opts.headers = opts.headers || {};
|
||||
opts.headers = {
|
||||
...opts.headers,
|
||||
...(this.target.headers || {}),
|
||||
...(this.from.user && {'X-Preferred-From-User': this.from.user}),
|
||||
...(this.from.host && {'X-Preferred-From-Host': this.from.host}),
|
||||
'X-Jambonz-Routing': this.target.type,
|
||||
'X-Call-Sid': this.callSid,
|
||||
...(this.applicationSid && {'X-Application-Sid': this.applicationSid}),
|
||||
...(this.target.proxy && {'X-SIP-Proxy': this.target.proxy})
|
||||
};
|
||||
if (srf.locals.fsUUID) {
|
||||
opts.headers = {
|
||||
...opts.headers,
|
||||
'X-Jambonz-FS-UUID': srf.locals.fsUUID,
|
||||
};
|
||||
}
|
||||
this.ms = ms;
|
||||
let uri, to, inviteSpan;
|
||||
try {
|
||||
switch (this.target.type) {
|
||||
case 'phone':
|
||||
case 'teams':
|
||||
assert(this.target.number);
|
||||
uri = `sip:${this.target.number}@${this.sbcAddress}`;
|
||||
to = this.target.number;
|
||||
if ('teams' === this.target.type) {
|
||||
assert(this.target.teamsInfo);
|
||||
opts.headers = {...opts.headers,
|
||||
'X-MS-Teams-FQDN': this.target.teamsInfo.ms_teams_fqdn,
|
||||
'X-MS-Teams-Tenant-FQDN': this.target.teamsInfo.tenant_fqdn
|
||||
};
|
||||
if (this.target.vmail === true) uri = `${uri};opaque=app:voicemail`;
|
||||
}
|
||||
break;
|
||||
case 'user':
|
||||
assert(this.target.name);
|
||||
const aor = this.target.name;
|
||||
uri = `sip:${this.target.name}`;
|
||||
to = this.target.name;
|
||||
|
||||
// need to send to the SBC registered on
|
||||
const reg = await registrar.query(aor);
|
||||
if (reg) {
|
||||
const sbc = selectSbc(reg.sbcAddress);
|
||||
if (sbc) {
|
||||
this.logger.debug(`SingleDialer:exec retrieved registration details for ${aor}, using sbc at ${sbc}`);
|
||||
this.sbcAddress = sbc;
|
||||
}
|
||||
if (this.target.overrideTo) {
|
||||
Object.assign(opts.headers, {
|
||||
'X-Override-To': this.target.overrideTo
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'sip':
|
||||
@@ -96,86 +140,189 @@ class SingleDialer extends Emitter {
|
||||
this.serviceUrl = srf.locals.serviceUrl;
|
||||
|
||||
this.ep = await ms.createEndpoint();
|
||||
this._configMsEndpoint();
|
||||
this.logger.debug(`SingleDialer:exec - created endpoint ${this.ep.uuid}`);
|
||||
let sdp;
|
||||
const connectStream = async(remoteSdp) => {
|
||||
if (remoteSdp !== sdp) {
|
||||
this.ep.modify(sdp = remoteSdp);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* were we killed whilst we were off getting an endpoint ?
|
||||
* https://github.com/jambonz/jambonz-feature-server/issues/30
|
||||
*/
|
||||
if (this.killed) {
|
||||
this.logger.info('SingleDialer:exec got quick CANCEL from caller, abort outdial');
|
||||
this.ep.destroy()
|
||||
.catch((err) => this.logger.error({err}, 'Error destroying endpoint'));
|
||||
return;
|
||||
}
|
||||
let lastSdp;
|
||||
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: this.ep.local.sdp
|
||||
localSdp: opts.opusFirst ? makeOpusFirst(localSdp) : localSdp
|
||||
});
|
||||
if (this.target.auth) opts.auth = this.target.auth;
|
||||
this.dlg = await srf.createUAC(uri, opts, {
|
||||
inviteSpan = this.startSpan('invite', {
|
||||
'invite.uri': uri,
|
||||
'invite.dest_type': this.target.type
|
||||
});
|
||||
|
||||
this.dlg = await srf.createUAC(uri, {...opts, followRedirects: true, keepUriOnRedirect: true}, {
|
||||
cbRequest: (err, req) => {
|
||||
if (err) {
|
||||
this.logger.error(err, 'SingleDialer:exec Error creating call');
|
||||
this.emit('callCreateFail', err);
|
||||
inviteSpan.setAttributes({
|
||||
'invite.status_code': 500,
|
||||
'invite.err': err.message
|
||||
});
|
||||
inviteSpan.end();
|
||||
return;
|
||||
}
|
||||
inviteSpan.setAttributes({'invite.call_id': req.get('Call-ID')});
|
||||
|
||||
/**
|
||||
* INVITE has been sent out
|
||||
* (a) create a CallInfo for this call
|
||||
* (a) create a logger for this call
|
||||
*/
|
||||
req.srf = srf;
|
||||
this.req = req;
|
||||
this.callInfo = new CallInfo({
|
||||
direction: CallDirection.Outbound,
|
||||
parentCallInfo: this.parentCallInfo,
|
||||
req,
|
||||
to,
|
||||
callSid: this.callSid
|
||||
callSid: this.callSid,
|
||||
traceId: this.rootSpan.traceId
|
||||
});
|
||||
if (this.dialTask && this.dialTask.tag !== null &&
|
||||
typeof this.dialTask.tag === 'object' && !Array.isArray(this.dialTask.tag)) {
|
||||
this.callInfo.customerData = this.dialTask.tag;
|
||||
}
|
||||
this.logger = srf.locals.parentLogger.child({
|
||||
callSid: this.callSid,
|
||||
parentCallSid: this.parentCallInfo.callSid,
|
||||
callId: this.callInfo.callId
|
||||
});
|
||||
this.inviteInProgress = req;
|
||||
this.emit('callStatusChange', {callStatus: CallStatus.Trying, sipStatus: 100});
|
||||
this.emit('callStatusChange', {
|
||||
callStatus: CallStatus.Trying,
|
||||
sipStatus: 100,
|
||||
sipReason: 'Trying'
|
||||
});
|
||||
},
|
||||
cbProvisional: (prov) => {
|
||||
const status = {sipStatus: prov.status};
|
||||
const status = {sipStatus: prov.status, sipReason: prov.reason};
|
||||
// Update call-id for sbc outbound INVITE
|
||||
this.callInfo.sbcCallid = prov.get('X-CID');
|
||||
if ([180, 183].includes(prov.status) && prov.body) {
|
||||
status.callStatus = CallStatus.EarlyMedia;
|
||||
if (connectStream(prov.body)) this.emit('earlyMedia');
|
||||
if (status.callStatus !== CallStatus.EarlyMedia) {
|
||||
status.callStatus = CallStatus.EarlyMedia;
|
||||
this.emit('earlyMedia');
|
||||
}
|
||||
connectStream(prov.body, opts.isVideoCall);
|
||||
}
|
||||
else status.callStatus = CallStatus.Ringing;
|
||||
this.emit('callStatusChange', status);
|
||||
}
|
||||
});
|
||||
connectStream(this.dlg.remote.sdp);
|
||||
await connectStream(this.dlg.remote.sdp, opts.isVideoCall);
|
||||
this.dlg.callSid = this.callSid;
|
||||
this.inviteInProgress = null;
|
||||
this.emit('callStatusChange', {sipStatus: 200, callStatus: CallStatus.InProgress});
|
||||
this.emit('callStatusChange', {
|
||||
sipStatus: 200,
|
||||
sipReason: 'OK',
|
||||
callStatus: CallStatus.InProgress
|
||||
});
|
||||
this.logger.debug(`SingleDialer:exec call connected: ${this.callSid}`);
|
||||
const connectTime = this.dlg.connectTime = moment();
|
||||
inviteSpan.setAttributes({'invite.status_code': 200});
|
||||
inviteSpan.end();
|
||||
|
||||
this.dlg.on('destroy', () => {
|
||||
|
||||
/* race condition: we were killed just as call was answered */
|
||||
if (this.killed) {
|
||||
this.logger.info(`SingleDialer:exec race condition - we were killed as call connected: ${this.callSid}`);
|
||||
const duration = moment().diff(connectTime, 'seconds');
|
||||
this.logger.debug('SingleDialer:exec called party hung up');
|
||||
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||
this.ep.destroy();
|
||||
});
|
||||
this.emit('callStatusChange', {
|
||||
callStatus: CallStatus.Completed,
|
||||
sipStatus: 487,
|
||||
sipReason: 'Request Terminated',
|
||||
duration
|
||||
});
|
||||
if (this.ep) this.ep.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
this.dlg
|
||||
.on('destroy', () => {
|
||||
const duration = moment().diff(connectTime, 'seconds');
|
||||
this.logger.debug('SingleDialer:exec called party hung up');
|
||||
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||
this.ep && this.ep.destroy();
|
||||
})
|
||||
.on('refresh', () => this.logger.info('SingleDialer:exec - dialog refreshed by uas'))
|
||||
.on('modify', async(req, res) => {
|
||||
try {
|
||||
if (this.ep) {
|
||||
if (this.dialTask && this.dialTask.isOnHoldEnabled) {
|
||||
this.logger.info('dial is onhold, emit event');
|
||||
this.emit('reinvite', req, res);
|
||||
} else {
|
||||
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');
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.logger.info('SingleDialer:exec: handling reINVITE with released media, emit event');
|
||||
this.emit('reinvite', req, res);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'Error handling reinvite');
|
||||
}
|
||||
})
|
||||
.on('refer', (req, res) => {
|
||||
this.emit('refer', this.callInfo, req, res);
|
||||
});
|
||||
|
||||
if (this.confirmHook) this._executeApp(this.confirmHook);
|
||||
else this.emit('accept');
|
||||
} catch (err) {
|
||||
this.inviteInProgress = null;
|
||||
const status = {callStatus: CallStatus.Failed};
|
||||
if (err instanceof SipError) {
|
||||
status.sipStatus = err.status;
|
||||
status.sipReason = err.reason;
|
||||
if (err.status === 487) status.callStatus = CallStatus.NoAnswer;
|
||||
else if ([486, 600].includes(err.status)) status.callStatus = CallStatus.Busy;
|
||||
this.logger.info(`SingleDialer:exec outdial failure ${err.status}`);
|
||||
inviteSpan?.setAttributes({'invite.status_code': err.status});
|
||||
inviteSpan?.end();
|
||||
}
|
||||
else {
|
||||
this.logger.error(err, 'SingleDialer:exec');
|
||||
status.sipStatus = 500;
|
||||
inviteSpan?.setAttributes({
|
||||
'invite.status_code': 500,
|
||||
'invite.err': err.message
|
||||
});
|
||||
inviteSpan?.end();
|
||||
}
|
||||
this.emit('callStatusChange', status);
|
||||
if (this.ep) this.ep.destroy();
|
||||
@@ -185,13 +332,19 @@ class SingleDialer extends Emitter {
|
||||
/**
|
||||
* kill the call in progress or the stable dialog, whichever we have
|
||||
*/
|
||||
async kill() {
|
||||
async kill(Reason) {
|
||||
this.killed = true;
|
||||
if (this.inviteInProgress) await this.inviteInProgress.cancel();
|
||||
else if (this.dlg && this.dlg.connected) {
|
||||
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
||||
this.logger.debug('SingleDialer:kill hanging up called party');
|
||||
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||
this.dlg.destroy();
|
||||
const headers = {
|
||||
...(Reason && {'X-Reason': Reason})
|
||||
};
|
||||
this.dlg.destroy({
|
||||
headers
|
||||
});
|
||||
}
|
||||
if (this.ep) {
|
||||
this.logger.debug(`SingleDialer:kill - deleting endpoint ${this.ep.uuid}`);
|
||||
@@ -199,6 +352,45 @@ 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');
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an application on the call after answer, e.g. call screening.
|
||||
* Once the application completes in some fashion, emit an 'accepted' event
|
||||
@@ -209,11 +401,17 @@ class SingleDialer extends Emitter {
|
||||
async _executeApp(confirmHook) {
|
||||
try {
|
||||
// retrieve set of tasks
|
||||
const tasks = await this.requestor.request(confirmHook, this.callInfo);
|
||||
|
||||
const json = await this.requestor.request('dial:confirm', confirmHook, this.callInfo.toJSON());
|
||||
if (!json || (Array.isArray(json) && json.length === 0)) {
|
||||
this.logger.info('SingleDialer:_executeApp: no tasks returned from confirm hook');
|
||||
this.emit('accept');
|
||||
return;
|
||||
}
|
||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
// verify it contains only allowed verbs
|
||||
const allowedTasks = tasks.filter((task) => {
|
||||
return [
|
||||
TaskPreconditions.None,
|
||||
TaskPreconditions.StableCall,
|
||||
TaskPreconditions.Endpoint
|
||||
].includes(task.preconditions);
|
||||
@@ -230,7 +428,10 @@ class SingleDialer extends Emitter {
|
||||
dlg: this.dlg,
|
||||
ep: this.ep,
|
||||
callInfo: this.callInfo,
|
||||
tasks
|
||||
accountInfo: this.accountInfo,
|
||||
tasks,
|
||||
rootSpan: this.rootSpan,
|
||||
req: this.req
|
||||
});
|
||||
await cs.exec();
|
||||
|
||||
@@ -239,20 +440,118 @@ class SingleDialer extends Emitter {
|
||||
} catch (err) {
|
||||
this.logger.debug(err, 'SingleDialer:_executeApp: error');
|
||||
this.emit('decline');
|
||||
if (this.dlg.connected) this.dlg.destroy();
|
||||
if (this.dlg.connected) {
|
||||
this.dlg.destroy();
|
||||
this.ep.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_notifyCallStatusChange({callStatus, sipStatus, duration}) {
|
||||
async doAdulting({logger, tasks, application}) {
|
||||
this.adulting = true;
|
||||
this.emit('adulting');
|
||||
if (this.ep) {
|
||||
await this.ep.unbridge()
|
||||
.catch((err) => this.logger.info({err}, 'SingleDialer:doAdulting - failed to unbridge ep'));
|
||||
this.ep.play('silence_stream://1000');
|
||||
}
|
||||
else {
|
||||
await this.reAnchorMedia();
|
||||
}
|
||||
|
||||
this.dlg.callSid = this.callSid;
|
||||
this.dlg.linkedSpanId = this.rootSpan.traceId;
|
||||
const rootSpan = new RootSpan('outbound-call', this.dlg);
|
||||
const newLogger = logger.child({traceId: rootSpan.traceId});
|
||||
//clone application from parent call with new requestor
|
||||
//parrent application will be closed in case the parent hangup
|
||||
const app = {...application};
|
||||
if ('WS' === app.call_hook?.method ||
|
||||
app.call_hook?.url.startsWith('ws://') || app.call_hook?.url.startsWith('wss://')) {
|
||||
if (app.call_hook?.url) app.call_hook.url += '/adulting';
|
||||
const requestor = new WsRequestor(logger, this.accountInfo.account.account_sid,
|
||||
app.call_hook, this.accountInfo.account.webhook_secret);
|
||||
app.requestor = requestor;
|
||||
app.notifier = requestor;
|
||||
app.call_hook.method = 'WS';
|
||||
}
|
||||
else {
|
||||
app.requestor = new HttpRequestor(logger, this.accountInfo.account.account_sid,
|
||||
app.call_hook, this.accountInfo.account.webhook_secret);
|
||||
if (app.call_status_hook) app.notifier = new HttpRequestor(logger,
|
||||
this.accountInfo.account.account_sid, app.call_status_hook,
|
||||
this.accountInfo.account.webhook_secret);
|
||||
else app.notifier = {request: () => {}, close: () => {}};
|
||||
}
|
||||
// Replace old application with new application.
|
||||
this.application = app;
|
||||
const cs = new AdultingCallSession({
|
||||
logger: newLogger,
|
||||
singleDialer: this,
|
||||
application: app,
|
||||
callInfo: this.callInfo,
|
||||
accountInfo: this.accountInfo,
|
||||
tasks,
|
||||
rootSpan
|
||||
});
|
||||
app.requestor.request('session:adulting', '/adulting', {
|
||||
...cs.callInfo.toJSON(),
|
||||
parentCallInfo: this.parentCallInfo.toJSON()
|
||||
}).catch((err) => {
|
||||
newLogger.error({err}, 'doAdulting: error sending adulting request');
|
||||
});
|
||||
|
||||
cs.req = this.req;
|
||||
// fixed hangup an adulting session does not send status callback Completed
|
||||
cs.wrapDialog(this.dlg);
|
||||
cs.exec().catch((err) => newLogger.error({err}, 'doAdulting: error executing session'));
|
||||
return cs;
|
||||
}
|
||||
|
||||
async releaseMediaToSBC(remoteSdp, localSdp, releaseMediaEntirely) {
|
||||
assert(this.dlg && this.dlg.connected && this.ep && typeof remoteSdp === 'string');
|
||||
const sdp = stripCodecs(this.logger, remoteSdp, localSdp) || remoteSdp;
|
||||
await this.dlg.modify(sdp, {
|
||||
headers: {
|
||||
'X-Reason': releaseMediaEntirely ? 'release-media-entirely' : 'release-media'
|
||||
}
|
||||
});
|
||||
try {
|
||||
await this.ep.destroy();
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'SingleDialer:releaseMediaToSBC: Error destroying endpoint');
|
||||
}
|
||||
this.ep = null;
|
||||
}
|
||||
|
||||
async reAnchorMedia(currentMediaRoute = MediaPath.PartialMedia) {
|
||||
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();
|
||||
await this.dlg.modify(this.ep.local.sdp, {
|
||||
headers: {
|
||||
'X-Reason': 'anchor-media'
|
||||
}
|
||||
});
|
||||
|
||||
if (currentMediaRoute === MediaPath.NoMedia) {
|
||||
this.logger.debug('SingleDialer:reAnchorMedia: repoint endpoint after no media');
|
||||
await this.ep.modify(this.dlg.remote.sdp);
|
||||
}
|
||||
}
|
||||
|
||||
_notifyCallStatusChange({callStatus, sipStatus, sipReason, duration}) {
|
||||
assert((typeof duration === 'number' && callStatus === CallStatus.Completed) ||
|
||||
(!duration && callStatus !== CallStatus.Completed),
|
||||
'duration MUST be supplied when call completed AND ONLY when call completed');
|
||||
|
||||
if (this.callInfo) {
|
||||
this.callInfo.updateCallStatus(callStatus, sipStatus);
|
||||
this.callInfo.updateCallStatus(callStatus, sipStatus, sipReason);
|
||||
if (typeof duration === 'number') this.callInfo.duration = duration;
|
||||
try {
|
||||
this.requestor.request(this.application.call_status_hook, this.callInfo.toJSON());
|
||||
this.notifier.request('call:status', this.application.call_status_hook, this.callInfo.toJSON());
|
||||
} catch (err) {
|
||||
this.logger.info(err, `SingleDialer:_notifyCallStatusChange error sending ${callStatus} ${sipStatus}`);
|
||||
}
|
||||
@@ -265,9 +564,16 @@ class SingleDialer extends Emitter {
|
||||
}
|
||||
}
|
||||
|
||||
function placeOutdial({logger, srf, ms, sbcAddress, target, opts, application, callInfo}) {
|
||||
const sd = new SingleDialer({logger, sbcAddress, target, opts, application, callInfo});
|
||||
sd.exec(srf, ms, opts);
|
||||
function placeOutdial({
|
||||
logger, srf, ms, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan, dialTask,
|
||||
onHoldMusic
|
||||
}) {
|
||||
const myOpts = deepcopy(opts);
|
||||
const sd = new SingleDialer({
|
||||
logger, sbcAddress, target, opts: myOpts, application, callInfo,
|
||||
accountInfo, rootSpan, startSpan, dialTask, onHoldMusic
|
||||
});
|
||||
sd.exec(srf, ms, myOpts);
|
||||
return sd;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
const bent = require('bent');
|
||||
const parseUrl = require('parse-url');
|
||||
const assert = require('assert');
|
||||
|
||||
const toBase64 = (str) => Buffer.from(str || '', 'utf8').toString('base64');
|
||||
|
||||
function basicAuth(username, password) {
|
||||
if (!username || !password) return {};
|
||||
const creds = `${username}:${password || ''}`;
|
||||
const header = `Basic ${toBase64(creds)}`;
|
||||
return {Authorization: header};
|
||||
}
|
||||
|
||||
function isRelativeUrl(u) {
|
||||
return typeof u === 'string' && u.startsWith('/');
|
||||
}
|
||||
const timeSeries = require('@jambonz/time-series');
|
||||
const {
|
||||
NODE_ENV,
|
||||
JAMBONES_TIME_SERIES_HOST
|
||||
} = require('../config');
|
||||
let alerter ;
|
||||
|
||||
function isAbsoluteUrl(u) {
|
||||
return typeof u === 'string' &&
|
||||
@@ -21,83 +12,42 @@ function isAbsoluteUrl(u) {
|
||||
}
|
||||
|
||||
class Requestor {
|
||||
constructor(logger, hook) {
|
||||
constructor(logger, account_sid, hook, secret) {
|
||||
assert(typeof hook === 'object');
|
||||
|
||||
this.logger = logger;
|
||||
this.url = hook.url;
|
||||
this.method = hook.method || 'POST';
|
||||
this.authHeader = basicAuth(hook.username, hook.password);
|
||||
|
||||
const u = parseUrl(this.url);
|
||||
const myPort = u.port ? `:${u.port}` : '';
|
||||
const baseUrl = this._baseUrl = `${u.protocol}://${u.resource}${myPort}`;
|
||||
|
||||
this.get = bent(baseUrl, 'GET', 'buffer', 200, 201);
|
||||
this.post = bent(baseUrl, 'POST', 'buffer', 200, 201);
|
||||
|
||||
this.username = hook.username;
|
||||
this.password = hook.password;
|
||||
this.secret = secret;
|
||||
this.account_sid = account_sid;
|
||||
|
||||
assert(isAbsoluteUrl(this.url));
|
||||
assert(['GET', 'POST'].includes(this.method));
|
||||
|
||||
const {stats} = require('../../').srf.locals;
|
||||
this.stats = stats;
|
||||
|
||||
if (!alerter) {
|
||||
alerter = timeSeries(logger, {
|
||||
host: JAMBONES_TIME_SERIES_HOST,
|
||||
commitSize: 50,
|
||||
commitInterval: 'test' === NODE_ENV ? 7 : 20
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get baseUrl() {
|
||||
return this._baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an HTTP request.
|
||||
* All requests use json bodies.
|
||||
* All requests expect a 200 statusCode on success
|
||||
* @param {object|string} hook - may be a absolute or relative url, or an object
|
||||
* @param {string} [hook.url] - an absolute or relative url
|
||||
* @param {string} [hook.method] - 'GET' or 'POST'
|
||||
* @param {string} [hook.username] - if basic auth is protecting the endpoint
|
||||
* @param {string} [hook.password] - if basic auth is protecting the endpoint
|
||||
* @param {object} [params] - request parameters
|
||||
*/
|
||||
async request(hook, params) {
|
||||
params = params || null;
|
||||
const url = hook.url || hook;
|
||||
const method = hook.method || 'POST';
|
||||
const {username, password} = typeof hook === 'object' ? hook : {};
|
||||
|
||||
assert.ok(url, 'Requestor:request url was not provided');
|
||||
assert.ok, (['GET', 'POST'].includes(method), `Requestor:request method must be 'GET' or 'POST' not ${method}`);
|
||||
|
||||
this.logger.debug({hook, params}, `Requestor:request ${method} ${url}`);
|
||||
const startAt = process.hrtime();
|
||||
|
||||
let buf;
|
||||
try {
|
||||
buf = isRelativeUrl(url) ?
|
||||
await this.post(url, params, this.authHeader) :
|
||||
await bent(method, 'buffer', 200, 201)(url, params, basicAuth(username, password));
|
||||
} catch (err) {
|
||||
this.logger.info({baseUrl: this.baseUrl, url: err.statusCode},
|
||||
`web callback returned unexpected error code ${err.statusCode}`);
|
||||
throw err;
|
||||
}
|
||||
const diff = process.hrtime(startAt);
|
||||
const time = diff[0] * 1e3 + diff[1] * 1e-6;
|
||||
const rtt = time.toFixed(0);
|
||||
if (buf) this.stats.histogram('app.hook.response_time', rtt, ['hook_type:app']);
|
||||
|
||||
if (buf && buf.toString().length > 0) {
|
||||
try {
|
||||
const json = JSON.parse(buf.toString());
|
||||
this.logger.info({response: json}, `Requestor:request ${method} ${url} succeeded in ${rtt}ms`);
|
||||
return json;
|
||||
}
|
||||
catch (err) {
|
||||
this.logger.debug({err, url, method}, `Requestor:request returned non-JSON content: '${buf.toString()}'`);
|
||||
}
|
||||
get Alerter() {
|
||||
if (!alerter) {
|
||||
alerter = timeSeries(this.logger, {
|
||||
host: JAMBONES_TIME_SERIES_HOST,
|
||||
commitSize: 50,
|
||||
commitInterval: 'test' === NODE_ENV ? 7 : 20
|
||||
});
|
||||
}
|
||||
return alerter;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,52 +1,120 @@
|
||||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
const {LifeCycleEvents, FS_UUID_SET_NAME} = require('./constants');
|
||||
const Emitter = require('events');
|
||||
const debug = require('debug')('jambonz:feature-server');
|
||||
const noopLogger = {info: () => {}, error: () => {}};
|
||||
const Srf = require('drachtio-srf');
|
||||
const debug = require('debug')('jambonz:sbc-inbound');
|
||||
const srfs = [];
|
||||
const {
|
||||
JAMBONES_SBCS,
|
||||
K8S,
|
||||
K8S_SBC_SIP_SERVICE_NAME,
|
||||
AWS_SNS_TOPIC_ARN,
|
||||
OPTIONS_PING_INTERVAL,
|
||||
AWS_REGION,
|
||||
NODE_ENV,
|
||||
JAMBONES_CLUSTER_ID,
|
||||
} = require('../config');
|
||||
|
||||
module.exports = (logger) => {
|
||||
logger = logger || noopLogger;
|
||||
let idxSbc = 0, idxSrfs = 0;
|
||||
let idxSbc = 0;
|
||||
let sbcs = [];
|
||||
|
||||
assert.ok(process.env.JAMBONES_SBCS, 'missing JAMBONES_SBCS env var');
|
||||
const sbcs = process.env.JAMBONES_SBCS
|
||||
.split(',')
|
||||
.map((sbc) => sbc.trim());
|
||||
assert.ok(sbcs.length, 'JAMBONES_SBCS env var is empty or misconfigured');
|
||||
logger.info({sbcs}, 'SBC inventory');
|
||||
if (JAMBONES_SBCS) {
|
||||
sbcs = JAMBONES_SBCS
|
||||
.split(',')
|
||||
.map((sbc) => sbc.trim());
|
||||
assert.ok(sbcs.length, 'JAMBONES_SBCS env var is empty or misconfigured');
|
||||
logger.info({sbcs}, 'SBC inventory');
|
||||
}
|
||||
else if (K8S && K8S_SBC_SIP_SERVICE_NAME) {
|
||||
sbcs = [`${K8S_SBC_SIP_SERVICE_NAME}:5060`];
|
||||
logger.info({sbcs}, 'SBC inventory');
|
||||
}
|
||||
|
||||
// listen for SNS lifecycle changes
|
||||
let lifecycleEmitter = new Emitter();
|
||||
let dryUpCalls = false;
|
||||
if (AWS_SNS_TOPIC_ARN && AWS_REGION) {
|
||||
|
||||
(async function() {
|
||||
try {
|
||||
lifecycleEmitter = await require('./aws-sns-lifecycle')(logger);
|
||||
|
||||
lifecycleEmitter
|
||||
.on('SubscriptionConfirmation', ({publicIp}) => {
|
||||
const {srf} = require('../..');
|
||||
srf.locals.publicIp = publicIp;
|
||||
})
|
||||
.on(LifeCycleEvents.ScaleIn, async() => {
|
||||
logger.info('AWS scale-in notification: begin drying up calls');
|
||||
dryUpCalls = true;
|
||||
lifecycleEmitter.operationalState = LifeCycleEvents.ScaleIn;
|
||||
|
||||
const {srf} = require('../..');
|
||||
const {writeSystemAlerts} = srf.locals;
|
||||
if (writeSystemAlerts) {
|
||||
const {SystemState, FEATURE_SERVER} = require('./constants');
|
||||
await writeSystemAlerts({
|
||||
system_component: FEATURE_SERVER,
|
||||
state : SystemState.GracefulShutdownInProgress,
|
||||
fields : {
|
||||
detail: `feature-server with process_id ${process.pid} shutdown in progress`,
|
||||
host: srf.locals?.ipv4
|
||||
}
|
||||
});
|
||||
}
|
||||
pingProxies(srf);
|
||||
|
||||
// if we have zero calls, we can complete the scale-in right
|
||||
setTimeout(() => {
|
||||
const calls = srf.locals.sessionTracker.count;
|
||||
if (calls === 0) {
|
||||
logger.info('scale-in can complete immediately as we have no calls in progress');
|
||||
lifecycleEmitter.completeScaleIn();
|
||||
}
|
||||
else {
|
||||
logger.info(`${calls} calls in progress; scale-in will complete when they are done`);
|
||||
}
|
||||
}, 5000);
|
||||
})
|
||||
.on(LifeCycleEvents.StandbyEnter, () => {
|
||||
dryUpCalls = true;
|
||||
const {srf} = require('../..');
|
||||
pingProxies(srf);
|
||||
|
||||
logger.info('AWS enter pending state notification: begin drying up calls');
|
||||
})
|
||||
.on(LifeCycleEvents.StandbyExit, () => {
|
||||
dryUpCalls = false;
|
||||
const {srf} = require('../..');
|
||||
pingProxies(srf);
|
||||
|
||||
logger.info('AWS enter pending state notification: re-enable calls');
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({err}, 'Failure creating SNS notifier, lifecycle events will be disabled');
|
||||
}
|
||||
})();
|
||||
}
|
||||
else if (K8S) {
|
||||
lifecycleEmitter.scaleIn = () => process.exit(0);
|
||||
}
|
||||
|
||||
assert.ok(process.env.JAMBONES_FEATURE_SERVERS, 'missing JAMBONES_FEATURE_SERVERS env var');
|
||||
const drachtio = process.env.JAMBONES_FEATURE_SERVERS
|
||||
.split(',')
|
||||
.map((fs) => {
|
||||
const arr = /^(.*):(.*):(.*)/.exec(fs);
|
||||
if (!arr) throw new Error('JAMBONES_FEATURE_SERVERS env var is misconfigured');
|
||||
const srf = new Srf();
|
||||
srf.connect({host: arr[1], port: arr[2], secret: arr[3]})
|
||||
.on('connect', (err, hp) => {
|
||||
if (err) return logger.info(err, `Error connecting to drachtio server at ${arr[1]}:${arr[2]}`);
|
||||
srfs.push(srf);
|
||||
logger.info(err, `Success connecting to drachtio at ${arr[1]}:${arr[2]}, ${srfs.length} online`);
|
||||
pingProxies(srf);
|
||||
})
|
||||
.on('error', (err) => {
|
||||
const place = srfs.indexOf(srf);
|
||||
if (-1 !== place) srfs.splice(place, 1);
|
||||
logger.info(err, `Error connecting to FS at ${arr[1]}:${arr[2]}, ${srfs.length} remain online`);
|
||||
});
|
||||
return {host: arr[1], port: arr[2], secret: arr[3]};
|
||||
});
|
||||
assert.ok(drachtio.length, 'JAMBONES_FEATURE_SERVERS env var is empty');
|
||||
logger.info({drachtio}, 'drachtio feature server inventory');
|
||||
|
||||
async function pingProxies(srf) {
|
||||
if (NODE_ENV === 'test') return;
|
||||
|
||||
for (const sbc of sbcs) {
|
||||
try {
|
||||
const ms = srf.locals.getFreeswitch();
|
||||
const req = await srf.request({
|
||||
uri: `sip:${sbc}`,
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
'X-FS-Status': 'open'
|
||||
'X-FS-Status': ms && !dryUpCalls ? 'open' : 'closed',
|
||||
'X-FS-Calls': srf.locals.sessionTracker.count,
|
||||
'X-FS-ServiceUrl': srf.locals.serviceUrl
|
||||
}
|
||||
});
|
||||
req.on('response', (res) => {
|
||||
@@ -57,15 +125,50 @@ module.exports = (logger) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (K8S) {
|
||||
setImmediate(() => {
|
||||
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 = crypto.randomUUID();
|
||||
|
||||
// OPTIONS ping the SBCs from each feature server every 60 seconds
|
||||
setInterval(() => {
|
||||
srfs.forEach((srf) => pingProxies(srf));
|
||||
}, 60000);
|
||||
/* in case redis is restarted, re-insert our key every so often */
|
||||
setInterval(() => {
|
||||
// eslint-disable-next-line max-len
|
||||
addToSet(FS_UUID_SET_NAME, uuid).catch((err) => logger.info({err}, `Error adding ${uuid} to set ${FS_UUID_SET_NAME}`));
|
||||
}, 30000);
|
||||
// eslint-disable-next-line max-len
|
||||
addToSet(FS_UUID_SET_NAME, uuid).catch((err) => logger.info({err}, `Error adding ${uuid} to set ${FS_UUID_SET_NAME}`));
|
||||
});
|
||||
}
|
||||
else {
|
||||
// OPTIONS ping the SBCs from each feature server every 60 seconds
|
||||
setInterval(() => {
|
||||
const {srf} = require('../..');
|
||||
pingProxies(srf);
|
||||
}, OPTIONS_PING_INTERVAL);
|
||||
|
||||
// initial ping once we are up
|
||||
setTimeout(async() => {
|
||||
|
||||
// if SBCs are auto-scaling, monitor them as they come and go
|
||||
const {srf} = require('../..');
|
||||
if (!JAMBONES_SBCS) {
|
||||
const {monitorSet} = srf.locals.dbHelpers;
|
||||
const setName = `${(JAMBONES_CLUSTER_ID || 'default')}:active-sip`;
|
||||
await monitorSet(setName, 10, (members) => {
|
||||
sbcs = members;
|
||||
logger.info(`sbc-pinger: SBC roster has changed, list of active SBCs is now ${sbcs}`);
|
||||
});
|
||||
}
|
||||
|
||||
pingProxies(srf);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return {
|
||||
getSBC: () => sbcs[idxSbc++ % sbcs.length],
|
||||
getSrf: () => srfs[idxSrfs++ % srfs.length]
|
||||
lifecycleEmitter,
|
||||
getSBC: () => sbcs[idxSbc++ % sbcs.length]
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user