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.

Files changed (54) hide show
  1. rasa/cli/run.py +10 -6
  2. rasa/cli/utils.py +7 -0
  3. rasa/core/actions/action.py +0 -6
  4. rasa/core/channels/channel.py +30 -0
  5. rasa/core/channels/voice_ready/audiocodes.py +52 -17
  6. rasa/core/channels/voice_ready/jambonz.py +25 -5
  7. rasa/core/channels/voice_ready/jambonz_protocol.py +4 -0
  8. rasa/core/channels/voice_stream/audiocodes.py +53 -9
  9. rasa/core/channels/voice_stream/genesys.py +146 -16
  10. rasa/core/information_retrieval/faiss.py +6 -62
  11. rasa/core/nlg/contextual_response_rephraser.py +3 -0
  12. rasa/core/policies/enterprise_search_policy.py +10 -1
  13. rasa/core/policies/flows/flow_executor.py +3 -38
  14. rasa/core/policies/intentless_policy.py +3 -0
  15. rasa/core/processor.py +27 -6
  16. rasa/core/utils.py +53 -0
  17. rasa/dialogue_understanding/coexistence/llm_based_router.py +8 -0
  18. rasa/dialogue_understanding/commands/cancel_flow_command.py +4 -59
  19. rasa/dialogue_understanding/commands/knowledge_answer_command.py +2 -2
  20. rasa/dialogue_understanding/commands/start_flow_command.py +0 -41
  21. rasa/dialogue_understanding/generator/command_generator.py +67 -0
  22. rasa/dialogue_understanding/generator/flow_retrieval.py +1 -4
  23. rasa/dialogue_understanding/generator/llm_based_command_generator.py +2 -12
  24. rasa/dialogue_understanding/generator/multi_step/multi_step_llm_command_generator.py +13 -0
  25. rasa/dialogue_understanding/generator/prompt_templates/command_prompt_v2_claude_3_5_sonnet_20240620_template.jinja2 +1 -1
  26. rasa/dialogue_understanding/generator/prompt_templates/command_prompt_v2_gpt_4o_2024_11_20_template.jinja2 +2 -5
  27. rasa/dialogue_understanding/generator/single_step/compact_llm_command_generator.py +22 -10
  28. rasa/dialogue_understanding/generator/single_step/single_step_llm_command_generator.py +27 -12
  29. rasa/dialogue_understanding/patterns/default_flows_for_patterns.yml +0 -61
  30. rasa/dialogue_understanding/processor/command_processor.py +7 -65
  31. rasa/dialogue_understanding/stack/utils.py +0 -38
  32. rasa/e2e_test/utils/validation.py +3 -3
  33. rasa/llm_fine_tuning/paraphrasing/conversation_rephraser.py +3 -0
  34. rasa/shared/core/constants.py +0 -8
  35. rasa/shared/core/domain.py +12 -3
  36. rasa/shared/core/flows/flow.py +0 -17
  37. rasa/shared/core/flows/flows_yaml_schema.json +3 -38
  38. rasa/shared/core/flows/steps/collect.py +5 -18
  39. rasa/shared/core/flows/utils.py +1 -16
  40. rasa/shared/core/slot_mappings.py +11 -5
  41. rasa/shared/nlu/constants.py +0 -1
  42. rasa/shared/utils/common.py +11 -1
  43. rasa/shared/utils/constants.py +3 -0
  44. rasa/shared/utils/llm.py +69 -23
  45. rasa/validator.py +1 -123
  46. rasa/version.py +1 -1
  47. {rasa_pro-3.13.0.dev1.dist-info → rasa_pro-3.13.0.dev3.dist-info}/METADATA +2 -2
  48. {rasa_pro-3.13.0.dev1.dist-info → rasa_pro-3.13.0.dev3.dist-info}/RECORD +51 -54
  49. rasa/core/actions/action_handle_digressions.py +0 -164
  50. rasa/dialogue_understanding/commands/handle_digressions_command.py +0 -144
  51. rasa/dialogue_understanding/patterns/handle_digressions.py +0 -81
  52. {rasa_pro-3.13.0.dev1.dist-info → rasa_pro-3.13.0.dev3.dist-info}/NOTICE +0 -0
  53. {rasa_pro-3.13.0.dev1.dist-info → rasa_pro-3.13.0.dev3.dist-info}/WHEEL +0 -0
  54. {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
- reason_str = f"'{model_path}' not found."
68
- if model_path is None:
69
- reason_str = f"Parameter '{parameter}' not set."
70
-
71
- logger.debug(f"{reason_str} Using default location '{default}' instead.")
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)]
@@ -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
 
@@ -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(request.json, ac_output, on_new_message),
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._get_conversation(request.token, conversation_id)
405
- reason = {"reason": request.json.get("reason")}
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 InputChannel, OutputChannel, UserMessage
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
- return cls()
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
- def __init__(self) -> None:
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("audiocodes.hangup")
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("audiocodes_stream.unknown_message", data=data)
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
- # TODO: validate API key header
270
- logger.info("audiocodes.receive", message="Starting audio streaming")
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
- "audiocodes.receive",
319
+ "audiocodes_stream.receive",
276
320
  message="Error during audio streaming",
277
321
  error=e,
278
322
  )
279
- # return 500 error
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__(self, *args: Any, **kwargs: Any) -> None:
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
- # validate required headers
293
- required_headers = [
294
- "audiohook-organization-id",
295
- "audiohook-correlation-id",
296
- "audiohook-session-id",
297
- "x-api-key",
298
- ]
299
-
300
- for header in required_headers:
301
- if header not in request.headers:
302
- await ws.close(1008, f"Missing required header: {header}")
303
- return
304
-
305
- # TODO: validate API key header
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
- await self.run_audio_streaming(on_new_message, ws)
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