rasa-pro 3.13.0.dev1__py3-none-any.whl → 3.13.0.dev3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of rasa-pro might be problematic. Click here for more details.
- rasa/cli/run.py +10 -6
- rasa/cli/utils.py +7 -0
- rasa/core/actions/action.py +0 -6
- rasa/core/channels/channel.py +30 -0
- rasa/core/channels/voice_ready/audiocodes.py +52 -17
- rasa/core/channels/voice_ready/jambonz.py +25 -5
- rasa/core/channels/voice_ready/jambonz_protocol.py +4 -0
- rasa/core/channels/voice_stream/audiocodes.py +53 -9
- rasa/core/channels/voice_stream/genesys.py +146 -16
- rasa/core/information_retrieval/faiss.py +6 -62
- rasa/core/nlg/contextual_response_rephraser.py +3 -0
- rasa/core/policies/enterprise_search_policy.py +10 -1
- rasa/core/policies/flows/flow_executor.py +3 -38
- rasa/core/policies/intentless_policy.py +3 -0
- rasa/core/processor.py +27 -6
- rasa/core/utils.py +53 -0
- rasa/dialogue_understanding/coexistence/llm_based_router.py +8 -0
- rasa/dialogue_understanding/commands/cancel_flow_command.py +4 -59
- rasa/dialogue_understanding/commands/knowledge_answer_command.py +2 -2
- rasa/dialogue_understanding/commands/start_flow_command.py +0 -41
- rasa/dialogue_understanding/generator/command_generator.py +67 -0
- rasa/dialogue_understanding/generator/flow_retrieval.py +1 -4
- rasa/dialogue_understanding/generator/llm_based_command_generator.py +2 -12
- rasa/dialogue_understanding/generator/multi_step/multi_step_llm_command_generator.py +13 -0
- rasa/dialogue_understanding/generator/prompt_templates/command_prompt_v2_claude_3_5_sonnet_20240620_template.jinja2 +1 -1
- rasa/dialogue_understanding/generator/prompt_templates/command_prompt_v2_gpt_4o_2024_11_20_template.jinja2 +2 -5
- rasa/dialogue_understanding/generator/single_step/compact_llm_command_generator.py +22 -10
- rasa/dialogue_understanding/generator/single_step/single_step_llm_command_generator.py +27 -12
- rasa/dialogue_understanding/patterns/default_flows_for_patterns.yml +0 -61
- rasa/dialogue_understanding/processor/command_processor.py +7 -65
- rasa/dialogue_understanding/stack/utils.py +0 -38
- rasa/e2e_test/utils/validation.py +3 -3
- rasa/llm_fine_tuning/paraphrasing/conversation_rephraser.py +3 -0
- rasa/shared/core/constants.py +0 -8
- rasa/shared/core/domain.py +12 -3
- rasa/shared/core/flows/flow.py +0 -17
- rasa/shared/core/flows/flows_yaml_schema.json +3 -38
- rasa/shared/core/flows/steps/collect.py +5 -18
- rasa/shared/core/flows/utils.py +1 -16
- rasa/shared/core/slot_mappings.py +11 -5
- rasa/shared/nlu/constants.py +0 -1
- rasa/shared/utils/common.py +11 -1
- rasa/shared/utils/constants.py +3 -0
- rasa/shared/utils/llm.py +69 -23
- rasa/validator.py +1 -123
- rasa/version.py +1 -1
- {rasa_pro-3.13.0.dev1.dist-info → rasa_pro-3.13.0.dev3.dist-info}/METADATA +2 -2
- {rasa_pro-3.13.0.dev1.dist-info → rasa_pro-3.13.0.dev3.dist-info}/RECORD +51 -54
- rasa/core/actions/action_handle_digressions.py +0 -164
- rasa/dialogue_understanding/commands/handle_digressions_command.py +0 -144
- rasa/dialogue_understanding/patterns/handle_digressions.py +0 -81
- {rasa_pro-3.13.0.dev1.dist-info → rasa_pro-3.13.0.dev3.dist-info}/NOTICE +0 -0
- {rasa_pro-3.13.0.dev1.dist-info → rasa_pro-3.13.0.dev3.dist-info}/WHEEL +0 -0
- {rasa_pro-3.13.0.dev1.dist-info → rasa_pro-3.13.0.dev3.dist-info}/entry_points.txt +0 -0
rasa/cli/run.py
CHANGED
|
@@ -64,12 +64,16 @@ def run_actions(args: argparse.Namespace) -> None:
|
|
|
64
64
|
|
|
65
65
|
def _validate_model_path(model_path: Text, parameter: Text, default: Text) -> Text:
|
|
66
66
|
if model_path is not None and not os.path.exists(model_path):
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
raise ModelNotFound(
|
|
68
|
+
f"The provided model path '{model_path}' could not be found. "
|
|
69
|
+
"Provide an existing model path."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if model_path is None:
|
|
73
|
+
logger.debug(
|
|
74
|
+
f"Parameter '{parameter}' not set. "
|
|
75
|
+
"Using default location '{default}' instead."
|
|
76
|
+
)
|
|
73
77
|
os.makedirs(default, exist_ok=True)
|
|
74
78
|
model_path = default
|
|
75
79
|
|
rasa/cli/utils.py
CHANGED
|
@@ -14,6 +14,7 @@ import structlog
|
|
|
14
14
|
import rasa.shared.utils.cli
|
|
15
15
|
import rasa.shared.utils.io
|
|
16
16
|
from rasa import telemetry
|
|
17
|
+
from rasa.exceptions import ModelNotFound
|
|
17
18
|
from rasa.shared.constants import (
|
|
18
19
|
ASSISTANT_ID_DEFAULT_VALUE,
|
|
19
20
|
ASSISTANT_ID_KEY,
|
|
@@ -77,6 +78,12 @@ def get_validated_path(
|
|
|
77
78
|
if current and os.path.exists(current):
|
|
78
79
|
return current
|
|
79
80
|
|
|
81
|
+
if parameter == "model":
|
|
82
|
+
raise ModelNotFound(
|
|
83
|
+
f"The provided model path '{current}' could not be found. "
|
|
84
|
+
"Provide an existing model path."
|
|
85
|
+
)
|
|
86
|
+
|
|
80
87
|
# try to find a valid option among the defaults
|
|
81
88
|
if isinstance(default, str) or isinstance(default, Path):
|
|
82
89
|
default_options = [str(default)]
|
rasa/core/actions/action.py
CHANGED
|
@@ -108,10 +108,6 @@ logger = logging.getLogger(__name__)
|
|
|
108
108
|
def default_actions(action_endpoint: Optional[EndpointConfig] = None) -> List["Action"]:
|
|
109
109
|
"""List default actions."""
|
|
110
110
|
from rasa.core.actions.action_clean_stack import ActionCleanStack
|
|
111
|
-
from rasa.core.actions.action_handle_digressions import (
|
|
112
|
-
ActionBlockDigressions,
|
|
113
|
-
ActionContinueDigression,
|
|
114
|
-
)
|
|
115
111
|
from rasa.core.actions.action_hangup import ActionHangup
|
|
116
112
|
from rasa.core.actions.action_repeat_bot_messages import ActionRepeatBotMessages
|
|
117
113
|
from rasa.core.actions.action_run_slot_rejections import ActionRunSlotRejections
|
|
@@ -146,8 +142,6 @@ def default_actions(action_endpoint: Optional[EndpointConfig] = None) -> List["A
|
|
|
146
142
|
ActionResetRouting(),
|
|
147
143
|
ActionHangup(),
|
|
148
144
|
ActionRepeatBotMessages(),
|
|
149
|
-
ActionBlockDigressions(),
|
|
150
|
-
ActionContinueDigression(),
|
|
151
145
|
]
|
|
152
146
|
|
|
153
147
|
|
rasa/core/channels/channel.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import hmac
|
|
1
2
|
import json
|
|
2
3
|
import logging
|
|
3
4
|
import uuid
|
|
5
|
+
from base64 import b64encode
|
|
6
|
+
from functools import wraps
|
|
4
7
|
from typing import (
|
|
5
8
|
Any,
|
|
6
9
|
Awaitable,
|
|
@@ -15,6 +18,7 @@ from typing import (
|
|
|
15
18
|
|
|
16
19
|
import jwt
|
|
17
20
|
from sanic import Blueprint, Sanic
|
|
21
|
+
from sanic.exceptions import Unauthorized
|
|
18
22
|
from sanic.request import Request
|
|
19
23
|
|
|
20
24
|
from rasa.cli import utils as cli_utils
|
|
@@ -454,3 +458,29 @@ class CollectingOutputChannel(OutputChannel):
|
|
|
454
458
|
self, recipient_id: Text, json_message: Dict[Text, Any], **kwargs: Any
|
|
455
459
|
) -> None:
|
|
456
460
|
await self._persist_message(self._message(recipient_id, custom=json_message))
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def requires_basic_auth(username: Optional[Text], password: Optional[Text]) -> Callable:
|
|
464
|
+
"""Decorator to require basic auth for a route."""
|
|
465
|
+
|
|
466
|
+
def decorator(func: Callable) -> Callable:
|
|
467
|
+
@wraps(func)
|
|
468
|
+
async def wrapper(request: Request, *args: Any, **kwargs: Any) -> Any:
|
|
469
|
+
if not username or not password:
|
|
470
|
+
return await func(request, *args, **kwargs)
|
|
471
|
+
|
|
472
|
+
auth = request.headers.get("Authorization")
|
|
473
|
+
if not auth or not auth.startswith("Basic "):
|
|
474
|
+
logger.error("Missing or invalid authorization header.")
|
|
475
|
+
raise Unauthorized("Missing or invalid authorization header.") # type: ignore[no-untyped-call]
|
|
476
|
+
|
|
477
|
+
encoded = b64encode(f"{username}:{password}".encode()).decode()
|
|
478
|
+
if not hmac.compare_digest(auth[6:], encoded):
|
|
479
|
+
logger.error("Invalid username or password.")
|
|
480
|
+
raise Unauthorized("Invalid username or password.") # type: ignore[no-untyped-call]
|
|
481
|
+
|
|
482
|
+
return await func(request, *args, **kwargs)
|
|
483
|
+
|
|
484
|
+
return wrapper
|
|
485
|
+
|
|
486
|
+
return decorator
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import copy
|
|
3
|
+
import hmac
|
|
3
4
|
import json
|
|
4
5
|
import uuid
|
|
5
6
|
from collections import defaultdict
|
|
@@ -114,11 +115,21 @@ class Conversation:
|
|
|
114
115
|
async def handle_activities(
|
|
115
116
|
self,
|
|
116
117
|
message: Dict[Text, Any],
|
|
118
|
+
input_channel_name: str,
|
|
117
119
|
output_channel: OutputChannel,
|
|
118
120
|
on_new_message: Callable[[UserMessage], Awaitable[Any]],
|
|
119
121
|
) -> None:
|
|
120
122
|
"""Handle activities sent by Audiocodes."""
|
|
121
123
|
structlogger.debug("audiocodes.handle.activities")
|
|
124
|
+
if input_channel_name == "":
|
|
125
|
+
structlogger.warning(
|
|
126
|
+
"audiocodes.handle.activities.empty_input_channel_name",
|
|
127
|
+
event_info=(
|
|
128
|
+
f"Audiocodes input channel name is empty "
|
|
129
|
+
f"for conversation {self.conversation_id}"
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
|
|
122
133
|
for activity in message["activities"]:
|
|
123
134
|
text = None
|
|
124
135
|
if activity[ACTIVITY_ID_KEY] in self.activity_ids:
|
|
@@ -142,6 +153,7 @@ class Conversation:
|
|
|
142
153
|
metadata = self.get_metadata(activity)
|
|
143
154
|
user_msg = UserMessage(
|
|
144
155
|
text=text,
|
|
156
|
+
input_channel=input_channel_name,
|
|
145
157
|
output_channel=output_channel,
|
|
146
158
|
sender_id=self.conversation_id,
|
|
147
159
|
metadata=metadata,
|
|
@@ -245,8 +257,13 @@ class AudiocodesInput(InputChannel):
|
|
|
245
257
|
|
|
246
258
|
def _check_token(self, token: Optional[Text]) -> None:
|
|
247
259
|
if not token:
|
|
260
|
+
structlogger.error("audiocodes.token_not_provided")
|
|
248
261
|
raise HttpUnauthorized("Authentication token required.")
|
|
249
262
|
|
|
263
|
+
if not hmac.compare_digest(str(token), str(self.token)):
|
|
264
|
+
structlogger.error("audiocodes.invalid_token", invalid_token=token)
|
|
265
|
+
raise HttpUnauthorized("Invalid authentication token.")
|
|
266
|
+
|
|
250
267
|
def _get_conversation(
|
|
251
268
|
self, token: Optional[Text], conversation_id: Text
|
|
252
269
|
) -> Conversation:
|
|
@@ -388,7 +405,12 @@ class AudiocodesInput(InputChannel):
|
|
|
388
405
|
# start a background task to handle activities
|
|
389
406
|
self._create_task(
|
|
390
407
|
conversation_id,
|
|
391
|
-
conversation.handle_activities(
|
|
408
|
+
conversation.handle_activities(
|
|
409
|
+
request.json,
|
|
410
|
+
input_channel_name=self.name(),
|
|
411
|
+
output_channel=ac_output,
|
|
412
|
+
on_new_message=on_new_message,
|
|
413
|
+
),
|
|
392
414
|
)
|
|
393
415
|
return response.json(response_json)
|
|
394
416
|
|
|
@@ -401,23 +423,9 @@ class AudiocodesInput(InputChannel):
|
|
|
401
423
|
Example of payload:
|
|
402
424
|
{"conversation": <conversation_id>, "reason": Optional[Text]}.
|
|
403
425
|
"""
|
|
404
|
-
self.
|
|
405
|
-
|
|
406
|
-
await on_new_message(
|
|
407
|
-
UserMessage(
|
|
408
|
-
text=f"{INTENT_MESSAGE_PREFIX}session_end",
|
|
409
|
-
output_channel=None,
|
|
410
|
-
sender_id=conversation_id,
|
|
411
|
-
metadata=reason,
|
|
412
|
-
)
|
|
413
|
-
)
|
|
414
|
-
del self.conversations[conversation_id]
|
|
415
|
-
structlogger.debug(
|
|
416
|
-
"audiocodes.disconnect",
|
|
417
|
-
conversation=conversation_id,
|
|
418
|
-
request=request.json,
|
|
426
|
+
return await self._handle_disconnect(
|
|
427
|
+
request, conversation_id, on_new_message
|
|
419
428
|
)
|
|
420
|
-
return response.json({})
|
|
421
429
|
|
|
422
430
|
@ac_webhook.route("/conversation/<conversation_id>/keepalive", methods=["POST"])
|
|
423
431
|
async def keepalive(request: Request, conversation_id: Text) -> HTTPResponse:
|
|
@@ -432,6 +440,32 @@ class AudiocodesInput(InputChannel):
|
|
|
432
440
|
|
|
433
441
|
return ac_webhook
|
|
434
442
|
|
|
443
|
+
async def _handle_disconnect(
|
|
444
|
+
self,
|
|
445
|
+
request: Request,
|
|
446
|
+
conversation_id: Text,
|
|
447
|
+
on_new_message: Callable[[UserMessage], Awaitable[Any]],
|
|
448
|
+
) -> HTTPResponse:
|
|
449
|
+
"""Triggered when the call is disconnected."""
|
|
450
|
+
self._get_conversation(request.token, conversation_id)
|
|
451
|
+
reason = {"reason": request.json.get("reason")}
|
|
452
|
+
await on_new_message(
|
|
453
|
+
UserMessage(
|
|
454
|
+
text=f"{INTENT_MESSAGE_PREFIX}session_end",
|
|
455
|
+
output_channel=None,
|
|
456
|
+
input_channel=self.name(),
|
|
457
|
+
sender_id=conversation_id,
|
|
458
|
+
metadata=reason,
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
del self.conversations[conversation_id]
|
|
462
|
+
structlogger.debug(
|
|
463
|
+
"audiocodes.disconnect",
|
|
464
|
+
conversation=conversation_id,
|
|
465
|
+
request=request.json,
|
|
466
|
+
)
|
|
467
|
+
return response.json({})
|
|
468
|
+
|
|
435
469
|
|
|
436
470
|
class AudiocodesOutput(OutputChannel):
|
|
437
471
|
@classmethod
|
|
@@ -439,6 +473,7 @@ class AudiocodesOutput(OutputChannel):
|
|
|
439
473
|
return CHANNEL_NAME
|
|
440
474
|
|
|
441
475
|
def __init__(self) -> None:
|
|
476
|
+
super().__init__()
|
|
442
477
|
self.messages: List[Dict] = []
|
|
443
478
|
|
|
444
479
|
async def add_message(self, message: Dict) -> None:
|
|
@@ -5,8 +5,14 @@ from sanic import Blueprint, Websocket, response # type: ignore[attr-defined]
|
|
|
5
5
|
from sanic.request import Request
|
|
6
6
|
from sanic.response import HTTPResponse
|
|
7
7
|
|
|
8
|
-
from rasa.core.channels.channel import
|
|
8
|
+
from rasa.core.channels.channel import (
|
|
9
|
+
InputChannel,
|
|
10
|
+
OutputChannel,
|
|
11
|
+
UserMessage,
|
|
12
|
+
requires_basic_auth,
|
|
13
|
+
)
|
|
9
14
|
from rasa.core.channels.voice_ready.jambonz_protocol import (
|
|
15
|
+
CHANNEL_NAME,
|
|
10
16
|
send_ws_hangup_message,
|
|
11
17
|
send_ws_text_message,
|
|
12
18
|
websocket_message_handler,
|
|
@@ -18,8 +24,6 @@ from rasa.utils.io import remove_emojis
|
|
|
18
24
|
|
|
19
25
|
structlogger = structlog.get_logger()
|
|
20
26
|
|
|
21
|
-
CHANNEL_NAME = "jambonz"
|
|
22
|
-
|
|
23
27
|
DEFAULT_HANGUP_DELAY_SECONDS = 1
|
|
24
28
|
|
|
25
29
|
|
|
@@ -32,12 +36,27 @@ class JambonzVoiceReadyInput(InputChannel):
|
|
|
32
36
|
|
|
33
37
|
@classmethod
|
|
34
38
|
def from_credentials(cls, credentials: Optional[Dict[Text, Any]]) -> InputChannel:
|
|
35
|
-
|
|
39
|
+
if not credentials:
|
|
40
|
+
return cls()
|
|
41
|
+
|
|
42
|
+
username = credentials.get("username")
|
|
43
|
+
password = credentials.get("password")
|
|
44
|
+
if (username is None) != (password is None):
|
|
45
|
+
raise RasaException(
|
|
46
|
+
"In Jambonz channel, either both username and password "
|
|
47
|
+
"or neither should be provided. "
|
|
48
|
+
)
|
|
36
49
|
|
|
37
|
-
|
|
50
|
+
return cls(username, password)
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self, username: Optional[Text] = None, password: Optional[Text] = None
|
|
54
|
+
) -> None:
|
|
38
55
|
"""Initializes the JambonzVoiceReadyInput channel."""
|
|
39
56
|
mark_as_beta_feature("Jambonz Channel")
|
|
40
57
|
validate_voice_license_scope()
|
|
58
|
+
self.username = username
|
|
59
|
+
self.password = password
|
|
41
60
|
|
|
42
61
|
def blueprint(
|
|
43
62
|
self, on_new_message: Callable[[UserMessage], Awaitable[Any]]
|
|
@@ -50,6 +69,7 @@ class JambonzVoiceReadyInput(InputChannel):
|
|
|
50
69
|
return response.json({"status": "ok"})
|
|
51
70
|
|
|
52
71
|
@jambonz_webhook.websocket("/websocket", subprotocols=["ws.jambonz.org"]) # type: ignore
|
|
72
|
+
@requires_basic_auth(self.username, self.password)
|
|
53
73
|
async def websocket(request: Request, ws: Websocket) -> None:
|
|
54
74
|
"""Triggered on new websocket connection."""
|
|
55
75
|
async for message in ws:
|
|
@@ -10,6 +10,7 @@ from rasa.core.channels.channel import UserMessage
|
|
|
10
10
|
from rasa.core.channels.voice_ready.utils import CallParameters
|
|
11
11
|
|
|
12
12
|
structlogger = structlog.get_logger()
|
|
13
|
+
CHANNEL_NAME = "jambonz"
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
@dataclass
|
|
@@ -206,6 +207,7 @@ async def handle_new_session(
|
|
|
206
207
|
output_channel=output_channel,
|
|
207
208
|
sender_id=message.call_sid,
|
|
208
209
|
metadata=asdict(message.call_params),
|
|
210
|
+
input_channel=CHANNEL_NAME,
|
|
209
211
|
)
|
|
210
212
|
await send_config_ack(message.message_id, ws)
|
|
211
213
|
await on_new_message(user_msg)
|
|
@@ -238,6 +240,7 @@ async def handle_gather_completed(
|
|
|
238
240
|
output_channel = JambonzWebsocketOutput(ws, transcript_result.call_sid)
|
|
239
241
|
user_msg = UserMessage(
|
|
240
242
|
text=most_likely_transcript.text,
|
|
243
|
+
input_channel=CHANNEL_NAME,
|
|
241
244
|
output_channel=output_channel,
|
|
242
245
|
sender_id=transcript_result.call_sid,
|
|
243
246
|
metadata={},
|
|
@@ -288,6 +291,7 @@ async def handle_call_status(
|
|
|
288
291
|
output_channel = JambonzWebsocketOutput(ws, call_status.call_sid)
|
|
289
292
|
user_msg = UserMessage(
|
|
290
293
|
text="/session_end",
|
|
294
|
+
input_channel=CHANNEL_NAME,
|
|
291
295
|
output_channel=output_channel,
|
|
292
296
|
sender_id=call_status.call_sid,
|
|
293
297
|
metadata={},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import base64
|
|
3
|
+
import hmac
|
|
3
4
|
import json
|
|
4
5
|
from typing import Any, Awaitable, Callable, Dict, Optional, Text
|
|
5
6
|
|
|
@@ -103,6 +104,7 @@ class AudiocodesVoiceInputChannel(VoiceInputChannel):
|
|
|
103
104
|
|
|
104
105
|
def __init__(
|
|
105
106
|
self,
|
|
107
|
+
token: Optional[Text],
|
|
106
108
|
server_url: str,
|
|
107
109
|
asr_config: Dict,
|
|
108
110
|
tts_config: Dict,
|
|
@@ -110,6 +112,22 @@ class AudiocodesVoiceInputChannel(VoiceInputChannel):
|
|
|
110
112
|
):
|
|
111
113
|
mark_as_beta_feature("Audiocodes (audiocodes_stream) Channel")
|
|
112
114
|
super().__init__(server_url, asr_config, tts_config, monitor_silence)
|
|
115
|
+
self.token = token
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def from_credentials(
|
|
119
|
+
cls, credentials: Optional[Dict[str, Any]]
|
|
120
|
+
) -> VoiceInputChannel:
|
|
121
|
+
if not credentials:
|
|
122
|
+
raise ValueError("No credentials given for Audiocodes voice channel.")
|
|
123
|
+
|
|
124
|
+
return cls(
|
|
125
|
+
token=credentials.get("token"),
|
|
126
|
+
server_url=credentials["server_url"],
|
|
127
|
+
asr_config=credentials["asr"],
|
|
128
|
+
tts_config=credentials["tts"],
|
|
129
|
+
monitor_silence=credentials.get("monitor_silence", False),
|
|
130
|
+
)
|
|
113
131
|
|
|
114
132
|
def channel_bytes_to_rasa_audio_bytes(self, input_bytes: bytes) -> RasaAudioBytes:
|
|
115
133
|
return RasaAudioBytes(base64.b64decode(input_bytes))
|
|
@@ -135,6 +153,13 @@ class AudiocodesVoiceInputChannel(VoiceInputChannel):
|
|
|
135
153
|
)
|
|
136
154
|
if activity["name"] == "start":
|
|
137
155
|
return map_call_params(activity["parameters"])
|
|
156
|
+
elif data["type"] == "connection.validate":
|
|
157
|
+
# not part of call flow; only sent when integration is created
|
|
158
|
+
logger.info(
|
|
159
|
+
"audiocodes_stream.collect_call_parameters.connection.validate",
|
|
160
|
+
event_info="received request to validate integration",
|
|
161
|
+
)
|
|
162
|
+
self._send_validated(channel_websocket, data)
|
|
138
163
|
else:
|
|
139
164
|
logger.warning("audiocodes_stream.unknown_message", data=data)
|
|
140
165
|
return None
|
|
@@ -158,7 +183,7 @@ class AudiocodesVoiceInputChannel(VoiceInputChannel):
|
|
|
158
183
|
elif activity["name"] == "playFinished":
|
|
159
184
|
logger.debug("audiocodes_stream.playFinished", data=activity)
|
|
160
185
|
if call_state.should_hangup:
|
|
161
|
-
logger.info("
|
|
186
|
+
logger.info("audiocodes_stream.hangup")
|
|
162
187
|
self._send_hangup(ws, data)
|
|
163
188
|
# the conversation should continue until
|
|
164
189
|
# we receive a end message from audiocodes
|
|
@@ -180,11 +205,10 @@ class AudiocodesVoiceInputChannel(VoiceInputChannel):
|
|
|
180
205
|
elif data["type"] == "session.end":
|
|
181
206
|
logger.debug("audiocodes_stream.end", data=data)
|
|
182
207
|
return EndConversationAction()
|
|
183
|
-
elif data["type"] == "connection.validate":
|
|
184
|
-
# not part of call flow; only sent when integration is created
|
|
185
|
-
self._send_validated(ws, data)
|
|
186
208
|
else:
|
|
187
|
-
logger.warning(
|
|
209
|
+
logger.warning(
|
|
210
|
+
"audiocodes_stream.map_input_message.unknown_message", data=data
|
|
211
|
+
)
|
|
188
212
|
|
|
189
213
|
return ContinueConversationAction()
|
|
190
214
|
|
|
@@ -254,6 +278,17 @@ class AudiocodesVoiceInputChannel(VoiceInputChannel):
|
|
|
254
278
|
self.tts_cache,
|
|
255
279
|
)
|
|
256
280
|
|
|
281
|
+
def _is_token_valid(self, token: Optional[Text]) -> bool:
|
|
282
|
+
# If no token is set, always return True
|
|
283
|
+
if not self.token:
|
|
284
|
+
return True
|
|
285
|
+
|
|
286
|
+
# Token is required, but not provided
|
|
287
|
+
if not token:
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
return hmac.compare_digest(str(self.token), str(token))
|
|
291
|
+
|
|
257
292
|
def blueprint(
|
|
258
293
|
self, on_new_message: Callable[[UserMessage], Awaitable[Any]]
|
|
259
294
|
) -> Blueprint:
|
|
@@ -266,17 +301,26 @@ class AudiocodesVoiceInputChannel(VoiceInputChannel):
|
|
|
266
301
|
|
|
267
302
|
@blueprint.websocket("/websocket") # type: ignore
|
|
268
303
|
async def receive(request: Request, ws: Websocket) -> None:
|
|
269
|
-
|
|
270
|
-
|
|
304
|
+
if not self._is_token_valid(request.token):
|
|
305
|
+
logger.error(
|
|
306
|
+
"audiocodes_stream.invalid_token",
|
|
307
|
+
invalid_token=request.token,
|
|
308
|
+
)
|
|
309
|
+
await ws.close(code=1008, reason="Invalid token")
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
logger.info(
|
|
313
|
+
"audiocodes_stream.receive", event_info="Started websocket connection"
|
|
314
|
+
)
|
|
271
315
|
try:
|
|
272
316
|
await self.run_audio_streaming(on_new_message, ws)
|
|
273
317
|
except Exception as e:
|
|
274
318
|
logger.exception(
|
|
275
|
-
"
|
|
319
|
+
"audiocodes_stream.receive",
|
|
276
320
|
message="Error during audio streaming",
|
|
277
321
|
error=e,
|
|
278
322
|
)
|
|
279
|
-
|
|
323
|
+
await ws.close(code=1011, reason="Error during audio streaming")
|
|
280
324
|
raise
|
|
281
325
|
|
|
282
326
|
return blueprint
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
2
5
|
import json
|
|
3
6
|
from typing import Any, Awaitable, Callable, Dict, Optional, Text
|
|
4
7
|
|
|
@@ -45,6 +48,7 @@ in the documentation but observed in their example app
|
|
|
45
48
|
https://github.com/GenesysCloudBlueprints/audioconnector-server-reference-implementation
|
|
46
49
|
"""
|
|
47
50
|
MAXIMUM_BINARY_MESSAGE_SIZE = 64000 # 64KB
|
|
51
|
+
HEADER_API_KEY = "X-Api-Key"
|
|
48
52
|
logger = structlog.get_logger(__name__)
|
|
49
53
|
|
|
50
54
|
|
|
@@ -86,8 +90,31 @@ class GenesysInputChannel(VoiceInputChannel):
|
|
|
86
90
|
def name(cls) -> str:
|
|
87
91
|
return "genesys"
|
|
88
92
|
|
|
89
|
-
def __init__(
|
|
93
|
+
def __init__(
|
|
94
|
+
self, api_key: Text, client_secret: Optional[Text], *args: Any, **kwargs: Any
|
|
95
|
+
) -> None:
|
|
90
96
|
super().__init__(*args, **kwargs)
|
|
97
|
+
self.api_key = api_key
|
|
98
|
+
self.client_secret = client_secret
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_credentials(
|
|
102
|
+
cls, credentials: Optional[Dict[str, Any]]
|
|
103
|
+
) -> VoiceInputChannel:
|
|
104
|
+
if not credentials:
|
|
105
|
+
raise ValueError("No credentials given for Genesys voice channel.")
|
|
106
|
+
|
|
107
|
+
if not credentials.get("api_key"):
|
|
108
|
+
raise ValueError("No API key given for Genesys voice channel (api_key).")
|
|
109
|
+
|
|
110
|
+
return cls(
|
|
111
|
+
api_key=credentials["api_key"],
|
|
112
|
+
client_secret=credentials.get("client_secret"),
|
|
113
|
+
server_url=credentials["server_url"],
|
|
114
|
+
asr_config=credentials["asr"],
|
|
115
|
+
tts_config=credentials["tts"],
|
|
116
|
+
monitor_silence=credentials.get("monitor_silence", False),
|
|
117
|
+
)
|
|
91
118
|
|
|
92
119
|
def _ensure_channel_data_initialized(self) -> None:
|
|
93
120
|
"""Initialize Genesys-specific channel data if not already present.
|
|
@@ -273,6 +300,93 @@ class GenesysInputChannel(VoiceInputChannel):
|
|
|
273
300
|
logger.debug("genesys.disconnect", message=message)
|
|
274
301
|
_schedule_ws_task(ws.send(json.dumps(message)))
|
|
275
302
|
|
|
303
|
+
def _calculate_signature(self, request: Request) -> str:
|
|
304
|
+
"""Calculate the signature using request data."""
|
|
305
|
+
org_id = request.headers.get("Audiohook-Organization-Id")
|
|
306
|
+
session_id = request.headers.get("Audiohook-Session-Id")
|
|
307
|
+
correlation_id = request.headers.get("Audiohook-Correlation-Id")
|
|
308
|
+
api_key = request.headers.get(HEADER_API_KEY)
|
|
309
|
+
|
|
310
|
+
# order of components is important!
|
|
311
|
+
components = [
|
|
312
|
+
("@request-target", "/webhooks/genesys/websocket"),
|
|
313
|
+
("audiohook-session-id", session_id),
|
|
314
|
+
("audiohook-organization-id", org_id),
|
|
315
|
+
("audiohook-correlation-id", correlation_id),
|
|
316
|
+
(HEADER_API_KEY.lower(), api_key),
|
|
317
|
+
("@authority", self.server_url),
|
|
318
|
+
]
|
|
319
|
+
|
|
320
|
+
# Create signature base string
|
|
321
|
+
signing_string = ""
|
|
322
|
+
for name, value in components:
|
|
323
|
+
signing_string += f'"{name}": {value}\n'
|
|
324
|
+
|
|
325
|
+
# Add @signature-params
|
|
326
|
+
signature_input = request.headers["Signature-Input"]
|
|
327
|
+
_, params_str = signature_input.split("=", 1)
|
|
328
|
+
signing_string += f'"@signature-params": {params_str}'
|
|
329
|
+
|
|
330
|
+
# Calculate the HMAC signature
|
|
331
|
+
key_bytes = base64.b64decode(self.client_secret)
|
|
332
|
+
signature = hmac.new(
|
|
333
|
+
key_bytes, signing_string.encode("utf-8"), hashlib.sha256
|
|
334
|
+
).digest()
|
|
335
|
+
return base64.b64encode(signature).decode("utf-8")
|
|
336
|
+
|
|
337
|
+
async def _verify_signature(self, request: Request) -> bool:
|
|
338
|
+
"""Verify the HTTP message signature from Genesys."""
|
|
339
|
+
if not self.client_secret:
|
|
340
|
+
logger.info(
|
|
341
|
+
"genesys.verify_signature.no_client_secret",
|
|
342
|
+
event_info="Signature verification skipped",
|
|
343
|
+
)
|
|
344
|
+
return True # Skip verification if no client secret
|
|
345
|
+
|
|
346
|
+
signature = request.headers.get("Signature")
|
|
347
|
+
signature_input = request.headers.get("Signature-Input")
|
|
348
|
+
if not signature or not signature_input:
|
|
349
|
+
logger.error("genesys.signature.missing_signature_header")
|
|
350
|
+
return False
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
actual_signature = signature.split("=", 1)[1].strip(':"')
|
|
354
|
+
expected_signature = self._calculate_signature(request)
|
|
355
|
+
return hmac.compare_digest(
|
|
356
|
+
expected_signature.encode("utf-8"), actual_signature.encode("utf-8")
|
|
357
|
+
)
|
|
358
|
+
except Exception as e:
|
|
359
|
+
logger.exception("genesys.signature.verification_error", error=e)
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
def _ensure_required_headers(self, request: Request) -> bool:
|
|
363
|
+
"""Ensure required headers are present in the request."""
|
|
364
|
+
required_headers = [
|
|
365
|
+
"Audiohook-Organization-Id",
|
|
366
|
+
"Audiohook-Correlation-Id",
|
|
367
|
+
"Audiohook-Session-Id",
|
|
368
|
+
HEADER_API_KEY,
|
|
369
|
+
]
|
|
370
|
+
|
|
371
|
+
missing_headers = [
|
|
372
|
+
header for header in required_headers if header not in request.headers
|
|
373
|
+
]
|
|
374
|
+
|
|
375
|
+
if missing_headers:
|
|
376
|
+
logger.error(
|
|
377
|
+
"genesys.missing_required_headers",
|
|
378
|
+
missing_headers=missing_headers,
|
|
379
|
+
)
|
|
380
|
+
return False
|
|
381
|
+
return True
|
|
382
|
+
|
|
383
|
+
def _ensure_api_key(self, request: Request) -> bool:
|
|
384
|
+
"""Ensure the API key is present in the request."""
|
|
385
|
+
api_key = request.headers.get(HEADER_API_KEY)
|
|
386
|
+
if not hmac.compare_digest(str(self.api_key), str(api_key)):
|
|
387
|
+
return False
|
|
388
|
+
return True
|
|
389
|
+
|
|
276
390
|
def blueprint(
|
|
277
391
|
self, on_new_message: Callable[[UserMessage], Awaitable[Any]]
|
|
278
392
|
) -> Blueprint:
|
|
@@ -289,23 +403,39 @@ class GenesysInputChannel(VoiceInputChannel):
|
|
|
289
403
|
"genesys.receive",
|
|
290
404
|
audiohook_session_id=request.headers.get("audiohook-session-id"),
|
|
291
405
|
)
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
"
|
|
296
|
-
"
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
406
|
+
|
|
407
|
+
# verify signature
|
|
408
|
+
if not await self._verify_signature(request):
|
|
409
|
+
logger.error("genesys.receive.invalid_signature")
|
|
410
|
+
await ws.close(code=1008, reason="Invalid signature")
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
# ensure required headers are present
|
|
414
|
+
if not self._ensure_required_headers(request):
|
|
415
|
+
await ws.close(code=1002, reason="Missing required headers")
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
# ensure API key is correct
|
|
419
|
+
if not self._ensure_api_key(request):
|
|
420
|
+
logger.error(
|
|
421
|
+
"genesys.receive.invalid_api_key",
|
|
422
|
+
invalid_api_key=request.headers.get(HEADER_API_KEY),
|
|
423
|
+
)
|
|
424
|
+
await ws.close(code=1008, reason="Invalid API key")
|
|
425
|
+
return
|
|
426
|
+
|
|
306
427
|
# process audio streaming
|
|
307
428
|
logger.info("genesys.receive", message="Starting audio streaming")
|
|
308
|
-
|
|
429
|
+
try:
|
|
430
|
+
await self.run_audio_streaming(on_new_message, ws)
|
|
431
|
+
except Exception as e:
|
|
432
|
+
logger.exception(
|
|
433
|
+
"genesys.receive",
|
|
434
|
+
message="Error during audio streaming",
|
|
435
|
+
error=e,
|
|
436
|
+
)
|
|
437
|
+
await ws.close(code=1011, reason="Error during audio streaming")
|
|
438
|
+
raise
|
|
309
439
|
|
|
310
440
|
return blueprint
|
|
311
441
|
|