ruff linting

This commit is contained in:
ssillerom
2026-01-28 18:02:51 +01:00
parent c5be67f293
commit 2612fae527

View File

@@ -9,14 +9,14 @@ Features:
- Input/output variables for Architect flow integration - Input/output variables for Architect flow integration
- DTMF event support - DTMF event support
- Barge-in (interruption) events - Barge-in (interruption) events
- Pause/resume support for hold scenarios - Pause/resume support for hold scenarios (optional)
Protocol Reference: Protocol Reference:
- https://developer.genesys.cloud/devapps/audiohook - https://developer.genesys.cloud/devapps/audiohook
Audio Format: Audio Format:
- PCMU (μ-law) at 8kHz sample rate (preferred) - PCMU (μ-law) at 8kHz sample rate (preferred)
- L16 (16-bit linear PCM) at 8kHz also supported - L16 (16-bit linear PCM) at 8kHz also supported
- Mono (external channel) or Stereo (external on left, internal on right) - Mono (external channel) or Stereo (external on left, internal on right)
""" """
@@ -49,6 +49,7 @@ from pipecat.serializers.base_serializer import FrameSerializer
class AudioHookMessageType(str, Enum): class AudioHookMessageType(str, Enum):
"""AudioHook protocol message types.""" """AudioHook protocol message types."""
OPEN = "open" OPEN = "open"
OPENED = "opened" OPENED = "opened"
CLOSE = "close" CLOSE = "close"
@@ -65,15 +66,17 @@ class AudioHookMessageType(str, Enum):
class AudioHookChannel(str, Enum): class AudioHookChannel(str, Enum):
"""AudioHook audio channel configuration.""" """AudioHook audio channel configuration."""
EXTERNAL = "external" # Customer audio only (mono) EXTERNAL = "external" # Customer audio only (mono)
INTERNAL = "internal" # Agent audio only (mono) INTERNAL = "internal" # Agent audio only (mono)
BOTH = "both" # Stereo: external=left, internal=right BOTH = "both" # Stereo: external=left, internal=right
class AudioHookMediaFormat(str, Enum): class AudioHookMediaFormat(str, Enum):
"""Supported audio formats.""" """Supported audio formats."""
PCMU = "PCMU" # μ-law, 8kHz PCMU = "PCMU" # μ-law, 8kHz
L16 = "L16" # 16-bit linear PCM, 8kHz L16 = "L16" # 16-bit linear PCM, 8kHz
class GenesysAudioHookSerializer(FrameSerializer): class GenesysAudioHookSerializer(FrameSerializer):
@@ -81,7 +84,7 @@ class GenesysAudioHookSerializer(FrameSerializer):
This serializer handles converting between Pipecat frames and Genesys This serializer handles converting between Pipecat frames and Genesys
AudioHook protocol messages. It supports: AudioHook protocol messages. It supports:
- Bidirectional audio streaming (PCMU at 8kHz) - Bidirectional audio streaming (PCMU at 8kHz)
- Automatic protocol handshake (open/opened, close/closed, ping/pong) - Automatic protocol handshake (open/opened, close/closed, ping/pong)
- Session lifecycle management with pause/resume support - Session lifecycle management with pause/resume support
@@ -102,7 +105,7 @@ class GenesysAudioHookSerializer(FrameSerializer):
selected_language="en-US", selected_language="en-US",
) )
) )
# Use with FastAPI WebSocket transport # Use with FastAPI WebSocket transport
transport = FastAPIWebsocketTransport( transport = FastAPIWebsocketTransport(
websocket=websocket, websocket=websocket,
@@ -113,11 +116,11 @@ class GenesysAudioHookSerializer(FrameSerializer):
audio_out_fixed_packet_size=1600, # Important: prevents 429 rate limiting from Genesys audio_out_fixed_packet_size=1600, # Important: prevents 429 rate limiting from Genesys
), ),
) )
# Access call information after connection # Access call information after connection
participant = serializer.participant # ani, dnis, etc. participant = serializer.participant # ani, dnis, etc.
input_vars = serializer.input_variables # Custom vars from Architect input_vars = serializer.input_variables # Custom vars from Architect
# Set output variables to return to Architect # Set output variables to return to Architect
serializer.set_output_variables({"intent": "billing", "resolved": True}) serializer.set_output_variables({"intent": "billing", "resolved": True})
``` ```
@@ -166,23 +169,23 @@ class GenesysAudioHookSerializer(FrameSerializer):
""" """
super().__init__(**kwargs) super().__init__(**kwargs)
self._params = params or GenesysAudioHookSerializer.InputParams() self._params = params or GenesysAudioHookSerializer.InputParams()
self._genesys_sample_rate = self._params.genesys_sample_rate self._genesys_sample_rate = self._params.genesys_sample_rate
self._sample_rate = 0 # Pipeline input rate, set in setup() self._sample_rate = 0 # Pipeline input rate, set in setup()
self._session_id = str(uuid.uuid4()) self._session_id = str(uuid.uuid4())
# Use Pipecat's official resampler if needed (SOXR) # Use Pipecat's official resampler if needed (SOXR)
# Only used for TTS output (16kHz → 8kHz), input goes without resampling # Only used for TTS output (16kHz → 8kHz), input goes without resampling
self._input_resampler = SOXRStreamAudioResampler() self._input_resampler = SOXRStreamAudioResampler()
self._output_resampler = SOXRStreamAudioResampler() self._output_resampler = SOXRStreamAudioResampler()
# Protocol state # Protocol state
self._client_seq = 0 self._client_seq = 0
self._server_seq = 0 self._server_seq = 0
self._is_open = False self._is_open = False
self._is_paused = False self._is_paused = False
self._position = timedelta(0) self._position = timedelta(0)
# Session metadata # Session metadata
self._conversation_id: Optional[str] = None self._conversation_id: Optional[str] = None
self._participant: Optional[Dict[str, Any]] = None self._participant: Optional[Dict[str, Any]] = None
@@ -200,7 +203,6 @@ class GenesysAudioHookSerializer(FrameSerializer):
self._register_event_handler("on_error") self._register_event_handler("on_error")
self._register_event_handler("on_dtmf") self._register_event_handler("on_dtmf")
@property @property
def session_id(self) -> str: def session_id(self) -> str:
"""Get the Genesys AudioHook session ID generated by the serializer.""" """Get the Genesys AudioHook session ID generated by the serializer."""
@@ -238,13 +240,13 @@ class GenesysAudioHookSerializer(FrameSerializer):
def set_output_variables(self, variables: Dict[str, Any]) -> None: def set_output_variables(self, variables: Dict[str, Any]) -> None:
"""Set custom output variables to send back to Genesys on close. """Set custom output variables to send back to Genesys on close.
These variables will be included in the 'closed' response when Genesys These variables will be included in the 'closed' response when Genesys
closes the connection, making them available in the Architect flow. closes the connection, making them available in the Architect flow.
Args: Args:
variables: Dictionary of custom variables to send to Genesys. variables: Dictionary of custom variables to send to Genesys.
Example: Example:
```python ```python
# During the conversation, collect data and set it # During the conversation, collect data and set it
@@ -267,13 +269,13 @@ class GenesysAudioHookSerializer(FrameSerializer):
""" """
self._sample_rate = self._params.sample_rate or frame.audio_in_sample_rate self._sample_rate = self._params.sample_rate or frame.audio_in_sample_rate
logger.debug(f"GenesysAudioHookSerializer setup with sample_rate={self._sample_rate}") logger.debug(f"GenesysAudioHookSerializer setup with sample_rate={self._sample_rate}")
def _format_position(self, position: timedelta) -> str: def _format_position(self, position: timedelta) -> str:
"""Format a timedelta as ISO 8601 duration string. """Format a timedelta as ISO 8601 duration string.
Args: Args:
position: The timedelta to format. position: The timedelta to format.
Returns: Returns:
ISO 8601 duration string (e.g., "PT1.5S"). ISO 8601 duration string (e.g., "PT1.5S").
""" """
@@ -282,10 +284,10 @@ class GenesysAudioHookSerializer(FrameSerializer):
def _parse_position(self, position_str: str) -> timedelta: def _parse_position(self, position_str: str) -> timedelta:
"""Parse an ISO 8601 duration string to timedelta. """Parse an ISO 8601 duration string to timedelta.
Args: Args:
position_str: ISO 8601 duration string (e.g., "PT1.5S"). position_str: ISO 8601 duration string (e.g., "PT1.5S").
Returns: Returns:
Corresponding timedelta. Corresponding timedelta.
""" """
@@ -310,16 +312,16 @@ class GenesysAudioHookSerializer(FrameSerializer):
include_position: bool = True, include_position: bool = True,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Create a protocol message with common fields. """Create a protocol message with common fields.
Based on the Genesys AudioHook protocol, responses include: Based on the Genesys AudioHook protocol, responses include:
- seq: Server's sequence number (incremented per message) - seq: Server's sequence number (incremented per message)
- clientseq: Echo of the client's last sequence number - clientseq: Echo of the client's last sequence number
Args: Args:
msg_type: The message type. msg_type: The message type.
parameters: Optional parameters object. parameters: Optional parameters object.
include_position: Whether to include position field. include_position: Whether to include position field.
Returns: Returns:
The message dictionary. The message dictionary.
""" """
@@ -331,13 +333,13 @@ class GenesysAudioHookSerializer(FrameSerializer):
"clientseq": self._client_seq, "clientseq": self._client_seq,
"id": self._session_id, "id": self._session_id,
} }
if include_position: if include_position:
msg["position"] = self._format_position(self._position) msg["position"] = self._format_position(self._position)
if parameters: if parameters:
msg["parameters"] = parameters msg["parameters"] = parameters
return msg return msg
def create_opened_response( def create_opened_response(
@@ -347,27 +349,27 @@ class GenesysAudioHookSerializer(FrameSerializer):
selected_language: Optional[str] = None, selected_language: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Create an 'opened' response message for the client. """Create an 'opened' response message for the client.
This should be sent in response to an 'open' message from Genesys. This should be sent in response to an 'open' message from Genesys.
Args: Args:
start_paused: Whether to start the session paused. start_paused: Whether to start the session paused.
supported_languages: List of supported language codes. supported_languages: List of supported language codes.
selected_language: The selected language code. selected_language: The selected language code.
Returns: Returns:
Dictionary of the opened response message. Dictionary of the opened response message.
""" """
# Build channels list based on configuration # Build channels list based on configuration
channels: list[str] = [] channels: list[str] = []
if self._params.channel == AudioHookChannel.EXTERNAL: if self._params.channel == AudioHookChannel.EXTERNAL:
channels = ["external"] channels = ["external"]
elif self._params.channel == AudioHookChannel.INTERNAL: elif self._params.channel == AudioHookChannel.INTERNAL:
channels = ["internal"] channels = ["internal"]
elif self._params.channel == AudioHookChannel.BOTH: elif self._params.channel == AudioHookChannel.BOTH:
channels = ["external", "internal"] channels = ["external", "internal"]
parameters = { parameters = {
"startPaused": start_paused, "startPaused": start_paused,
"media": [ "media": [
@@ -379,22 +381,22 @@ class GenesysAudioHookSerializer(FrameSerializer):
} }
], ],
} }
if supported_languages: if supported_languages:
parameters["supportedLanguages"] = supported_languages parameters["supportedLanguages"] = supported_languages
if selected_language: if selected_language:
parameters["selectedLanguage"] = selected_language parameters["selectedLanguage"] = selected_language
msg = self._create_message( msg = self._create_message(
AudioHookMessageType.OPENED, AudioHookMessageType.OPENED,
parameters=parameters, parameters=parameters,
include_position=False, # opened doesn't need position include_position=False, # opened doesn't need position
) )
self._is_open = True self._is_open = True
logger.debug(f"AudioHook session opened: {self._session_id}") logger.debug(f"AudioHook session opened: {self._session_id}")
return msg return msg
def create_closed_response( def create_closed_response(
@@ -402,17 +404,17 @@ class GenesysAudioHookSerializer(FrameSerializer):
output_variables: Optional[Dict[str, Any]] = None, output_variables: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Create a 'closed' response message. """Create a 'closed' response message.
This should be sent in response to a 'close' message from Genesys. This should be sent in response to a 'close' message from Genesys.
Args: Args:
output_variables: Optional custom variables to pass back to Genesys. output_variables: Optional custom variables to pass back to Genesys.
These will be available in the Architect flow after the AudioHook These will be available in the Architect flow after the AudioHook
action completes. action completes.
Returns: Returns:
Dictionary of the closed response message. Dictionary of the closed response message.
Example: Example:
```python ```python
# Pass custom data back to Genesys # Pass custom data back to Genesys
@@ -426,25 +428,25 @@ class GenesysAudioHookSerializer(FrameSerializer):
``` ```
""" """
parameters: Optional[Dict[str, Any]] = None parameters: Optional[Dict[str, Any]] = None
if output_variables: if output_variables:
parameters = {"outputVariables": output_variables} parameters = {"outputVariables": output_variables}
msg = self._create_message( msg = self._create_message(
AudioHookMessageType.CLOSED, AudioHookMessageType.CLOSED,
parameters=parameters, parameters=parameters,
) )
self._is_open = False self._is_open = False
logger.debug(f"AudioHook session closed: {self._session_id}") logger.debug(f"AudioHook session closed: {self._session_id}")
return msg return msg
def create_pong_response(self) -> Dict[str, Any]: def create_pong_response(self) -> Dict[str, Any]:
"""Create a 'pong' response message. """Create a 'pong' response message.
This should be sent in response to a 'ping' message from Genesys. This should be sent in response to a 'ping' message from Genesys.
Returns: Returns:
Dictionary of the pong response message. Dictionary of the pong response message.
""" """
@@ -453,39 +455,35 @@ class GenesysAudioHookSerializer(FrameSerializer):
def create_resumed_response(self) -> Dict[str, Any]: def create_resumed_response(self) -> Dict[str, Any]:
"""Create a 'resumed' response message. """Create a 'resumed' response message.
This should be sent in response to a 'pause' message when ready to resume. This should be sent in response to a 'pause' message when ready to resume.
Returns: Returns:
Dictionary of the resumed response message. Dictionary of the resumed response message.
""" """
msg = self._create_message(AudioHookMessageType.RESUMED) msg = self._create_message(AudioHookMessageType.RESUMED)
self._is_paused = False self._is_paused = False
logger.debug(f"AudioHook session resumed: {self._session_id}") logger.debug(f"AudioHook session resumed: {self._session_id}")
return msg return msg
def create_barge_in_event(self) -> Dict[str, Any]: def create_barge_in_event(self) -> Dict[str, Any]:
"""Create a barge-in event message. """Create a barge-in event message.
This notifies Genesys Cloud that the user has interrupted the bot's This notifies Genesys Cloud that the user has interrupted the bot's
audio output. Genesys will stop any queued audio playback. audio output. Genesys will stop any queued audio playback.
Returns: Returns:
Dictionary of the barge-in event message. Dictionary of the barge-in event message.
""" """
msg = self._create_message( msg = self._create_message(
AudioHookMessageType.EVENT, AudioHookMessageType.EVENT,
parameters={ parameters={"entities": [{"type": "barge_in", "data": {}}]},
"entities": [
{"type": "barge_in", "data": {}}
]
},
) )
logger.debug("🔇 Barge-in event sent to Genesys") logger.debug("🔇 Barge-in event sent to Genesys")
return msg return msg
def create_disconnect_message( def create_disconnect_message(
@@ -496,32 +494,32 @@ class GenesysAudioHookSerializer(FrameSerializer):
info: Optional[str] = None, info: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Create a 'disconnect' message to initiate session termination. """Create a 'disconnect' message to initiate session termination.
Args: Args:
reason: Disconnect reason (e.g., "completed", "error"). reason: Disconnect reason (e.g., "completed", "error").
action: Action to take ("transfer" to agent, "finished" if completed). action: Action to take ("transfer" to agent, "finished" if completed).
output_variables: Custom output variables to pass back to Genesys. output_variables: Custom output variables to pass back to Genesys.
info: Optional additional information. info: Optional additional information.
Returns: Returns:
Dictionary of the disconnect message. Dictionary of the disconnect message.
""" """
parameters: Dict[str, Any] = {"reason": reason} parameters: Dict[str, Any] = {"reason": reason}
# Build outputVariables # Build outputVariables
out_vars = {"action": action} out_vars = {"action": action}
if output_variables: if output_variables:
out_vars.update(output_variables) out_vars.update(output_variables)
parameters["outputVariables"] = out_vars parameters["outputVariables"] = out_vars
if info: if info:
parameters["info"] = info parameters["info"] = info
msg = self._create_message( msg = self._create_message(
AudioHookMessageType.DISCONNECT, AudioHookMessageType.DISCONNECT,
parameters=parameters, parameters=parameters,
) )
logger.debug(f"AudioHook disconnect: reason={reason}, action={action}") logger.debug(f"AudioHook disconnect: reason={reason}, action={action}")
return msg return msg
@@ -532,12 +530,12 @@ class GenesysAudioHookSerializer(FrameSerializer):
retryable: bool = False, retryable: bool = False,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Create an 'error' message. """Create an 'error' message.
Args: Args:
code: Error code. code: Error code.
message: Error message. message: Error message.
retryable: Whether the operation can be retried. retryable: Whether the operation can be retried.
Returns: Returns:
Dictionary of the error message. Dictionary of the error message.
""" """
@@ -546,12 +544,12 @@ class GenesysAudioHookSerializer(FrameSerializer):
"message": message, "message": message,
"retryable": retryable, "retryable": retryable,
} }
msg = self._create_message( msg = self._create_message(
AudioHookMessageType.ERROR, AudioHookMessageType.ERROR,
parameters=parameters, parameters=parameters,
) )
logger.error(f"AudioHook error: {code} - {message}") logger.error(f"AudioHook error: {code} - {message}")
return msg return msg
@@ -572,14 +570,18 @@ class GenesysAudioHookSerializer(FrameSerializer):
the frame type is not handled or session is not open. the frame type is not handled or session is not open.
""" """
if isinstance(frame, (EndFrame, CancelFrame)): if isinstance(frame, (EndFrame, CancelFrame)):
return json.dumps(self.create_disconnect_message(output_variables=self.output_variables, reason="completed")) return json.dumps(
self.create_disconnect_message(
output_variables=self.output_variables, reason="completed"
)
)
elif isinstance(frame, AudioRawFrame): elif isinstance(frame, AudioRawFrame):
if not self._is_open or self._is_paused: if not self._is_open or self._is_paused:
return None return None
data = frame.audio data = frame.audio
# Convert PCM to μ-law at 8kHz for Genesys # Convert PCM to μ-law at 8kHz for Genesys
if self._params.media_format == AudioHookMediaFormat.PCMU: if self._params.media_format == AudioHookMediaFormat.PCMU:
serialized_data = await pcm_to_ulaw( serialized_data = await pcm_to_ulaw(
@@ -592,15 +594,15 @@ class GenesysAudioHookSerializer(FrameSerializer):
# L16 format - just resample if needed # L16 format - just resample if needed
logger.warning("L16 format not yet fully implemented") logger.warning("L16 format not yet fully implemented")
return None return None
if serialized_data is None or len(serialized_data) == 0: if serialized_data is None or len(serialized_data) == 0:
return None return None
return bytes(serialized_data) return bytes(serialized_data)
elif isinstance(frame, InterruptionFrame): elif isinstance(frame, InterruptionFrame):
return json.dumps(self.create_barge_in_event()) return json.dumps(self.create_barge_in_event())
elif isinstance(frame, (OutputTransportMessageFrame, OutputTransportMessageUrgentFrame)): elif isinstance(frame, (OutputTransportMessageFrame, OutputTransportMessageUrgentFrame)):
# Only pass through AudioHook protocol messages (those with "version" field) # Only pass through AudioHook protocol messages (those with "version" field)
# Filter out RTVI and other non-AudioHook messages # Filter out RTVI and other non-AudioHook messages
@@ -609,7 +611,7 @@ class GenesysAudioHookSerializer(FrameSerializer):
else: else:
# Not an AudioHook message, ignore # Not an AudioHook message, ignore
return None return None
# Ignore other frames - we don't need to process them here # Ignore other frames - we don't need to process them here
return None return None
@@ -639,39 +641,41 @@ class GenesysAudioHookSerializer(FrameSerializer):
if isinstance(data, bytes): if isinstance(data, bytes):
logger.debug(f"[AUDIO IN] Received {len(data)} bytes from Genesys") logger.debug(f"[AUDIO IN] Received {len(data)} bytes from Genesys")
return await self._deserialize_audio(data) return await self._deserialize_audio(data)
# Text data = JSON control message # Text data = JSON control message
try: try:
message = json.loads(data) message = json.loads(data)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error(f"Failed to parse AudioHook message: {e}") logger.error(f"Failed to parse AudioHook message: {e}")
return None return None
return await self._handle_control_message(message) return await self._handle_control_message(message)
async def _deserialize_audio(self, data: bytes) -> Frame | None: async def _deserialize_audio(self, data: bytes) -> Frame | None:
"""Deserialize binary audio data to an InputAudioRawFrame. """Deserialize binary audio data to an InputAudioRawFrame.
Args: Args:
data: Raw audio bytes (PCMU or L16). data: Raw audio bytes (PCMU or L16).
Returns: Returns:
InputAudioRawFrame with PCM audio at pipeline sample rate. InputAudioRawFrame with PCM audio at pipeline sample rate.
""" """
if not self._is_open or self._is_paused: if not self._is_open or self._is_paused:
return None return None
audio_data = data audio_data = data
original_len = len(data) original_len = len(data)
# If Genesys sends stereo audio (BOTH channels), extract only the external channel (left) # If Genesys sends stereo audio (BOTH channels), extract only the external channel (left)
# Stereo audio comes interleaved: [L0, R0, L1, R1, ...] # Stereo audio comes interleaved: [L0, R0, L1, R1, ...]
if self._params.channel == AudioHookChannel.BOTH and len(data) > 0: if self._params.channel == AudioHookChannel.BOTH and len(data) > 0:
# For PCMU, each sample is 1 byte # For PCMU, each sample is 1 byte
# Extract only bytes at even positions (left channel = external) # Extract only bytes at even positions (left channel = external)
audio_data = bytes(data[i] for i in range(0, len(data), 2)) audio_data = bytes(data[i] for i in range(0, len(data), 2))
logger.debug(f"🔊 Stereo audio: {original_len} bytes → {len(audio_data)} bytes (external channel)") logger.debug(
f"🔊 Stereo audio: {original_len} bytes → {len(audio_data)} bytes (external channel)"
)
if self._params.media_format == AudioHookMediaFormat.PCMU: if self._params.media_format == AudioHookMediaFormat.PCMU:
# Convert μ-law at 8kHz to PCM at pipeline rate # Convert μ-law at 8kHz to PCM at pipeline rate
deserialized_data = await ulaw_to_pcm( deserialized_data = await ulaw_to_pcm(
@@ -684,63 +688,62 @@ class GenesysAudioHookSerializer(FrameSerializer):
# L16 format # L16 format
logger.warning("L16 format not yet fully implemented") logger.warning("L16 format not yet fully implemented")
return None return None
if deserialized_data is None or len(deserialized_data) == 0: if deserialized_data is None or len(deserialized_data) == 0:
return None return None
# Always use mono for STT - ElevenLabs expects single channel # Always use mono for STT - ElevenLabs expects single channel
num_channels = 1 num_channels = 1
audio_frame = InputAudioRawFrame( audio_frame = InputAudioRawFrame(
audio=deserialized_data, audio=deserialized_data,
num_channels=num_channels, num_channels=num_channels,
sample_rate=self._sample_rate, sample_rate=self._sample_rate,
) )
return audio_frame return audio_frame
async def _handle_control_message(self, message: Dict[str, Any]) -> Frame | None: async def _handle_control_message(self, message: Dict[str, Any]) -> Frame | None:
"""Handle a JSON control message from Genesys. """Handle a JSON control message from Genesys.
Args: Args:
message: Parsed JSON message. message: Parsed JSON message.
Returns: Returns:
Frame if the message should be passed to the pipeline, None otherwise. Frame if the message should be passed to the pipeline, None otherwise.
""" """
msg_type = message.get("type", "") msg_type = message.get("type", "")
self._client_seq = message.get("seq", 0) self._client_seq = message.get("seq", 0)
# Update position if provided # Update position if provided
if "position" in message: if "position" in message:
self._position = self._parse_position(message["position"]) self._position = self._parse_position(message["position"])
if msg_type == AudioHookMessageType.OPEN.value: if msg_type == AudioHookMessageType.OPEN.value:
return await self._handle_open(message) return await self._handle_open(message)
elif msg_type == AudioHookMessageType.CLOSE.value: elif msg_type == AudioHookMessageType.CLOSE.value:
return await self._handle_close(message) return await self._handle_close(message)
elif msg_type == AudioHookMessageType.PING.value: elif msg_type == AudioHookMessageType.PING.value:
return await self._handle_ping(message) return await self._handle_ping(message)
elif msg_type == AudioHookMessageType.PAUSE.value: elif msg_type == AudioHookMessageType.PAUSE.value:
return await self._handle_pause(message) return await self._handle_pause(message)
elif msg_type == AudioHookMessageType.UPDATE.value: elif msg_type == AudioHookMessageType.UPDATE.value:
return await self._handle_update(message) return await self._handle_update(message)
elif msg_type == AudioHookMessageType.ERROR.value: elif msg_type == AudioHookMessageType.ERROR.value:
return await self._handle_error(message) return await self._handle_error(message)
elif msg_type == "dtmf": elif msg_type == "dtmf":
return await self._handle_dtmf(message) return await self._handle_dtmf(message)
elif msg_type == "playback_started": elif msg_type == "playback_started":
logger.debug("Playback started (from Genesys)") logger.debug("Playback started (from Genesys)")
return None return None
elif msg_type == "playback_completed": elif msg_type == "playback_completed":
logger.debug("Playback completed (from Genesys)") logger.debug("Playback completed (from Genesys)")
return None return None
@@ -750,40 +753,42 @@ class GenesysAudioHookSerializer(FrameSerializer):
async def _handle_open(self, message: Dict[str, Any]) -> Frame | None: async def _handle_open(self, message: Dict[str, Any]) -> Frame | None:
"""Handle an 'open' message from Genesys. """Handle an 'open' message from Genesys.
This initializes the session with metadata from Genesys Cloud and This initializes the session with metadata from Genesys Cloud and
automatically responds with an 'opened' message using the configured automatically responds with an 'opened' message using the configured
InputParams (supported_languages, selected_language, start_paused). InputParams (supported_languages, selected_language, start_paused).
Extracts and stores: Extracts and stores:
- session_id: The AudioHook session identifier - session_id: The AudioHook session identifier
- conversation_id: The Genesys conversation ID - conversation_id: The Genesys conversation ID
- participant: Caller info (ani, dnis, etc.) - participant: Caller info (ani, dnis, etc.)
- input_variables: Custom variables from Architect flow - input_variables: Custom variables from Architect flow
- media_info: Audio configuration from Genesys - media_info: Audio configuration from Genesys
Args: Args:
message: The open message from Genesys. message: The open message from Genesys.
Returns: Returns:
OutputTransportMessageUrgentFrame with the 'opened' response. OutputTransportMessageUrgentFrame with the 'opened' response.
""" """
self._session_id = message.get("id", str(uuid.uuid4())) self._session_id = message.get("id", str(uuid.uuid4()))
params = message.get("parameters", {}) params = message.get("parameters", {})
self._conversation_id = params.get("conversationId") self._conversation_id = params.get("conversationId")
self._participant = params.get("participant") self._participant = params.get("participant")
self._custom_config = params.get("customConfig") self._custom_config = params.get("customConfig")
self._media_info = params.get("media") # This is a list of media objects self._media_info = params.get("media") # This is a list of media objects
self._input_variables = params.get("inputVariables") # Custom vars from Genesys self._input_variables = params.get("inputVariables") # Custom vars from Genesys
# Extract media configuration if present # Extract media configuration if present
# media is a list like: [{"type": "audio", "format": "PCMU", "channels": ["external"], "rate": 8000}] # media is a list like: [{"type": "audio", "format": "PCMU", "channels": ["external"], "rate": 8000}]
media_list = self._media_info media_list = self._media_info
if media_list and isinstance(media_list, list) and len(media_list) > 0: if media_list and isinstance(media_list, list) and len(media_list) > 0:
audio_media: Dict[str, Any] = media_list[0] # Get first media entry audio_media: Dict[str, Any] = media_list[0] # Get first media entry
channels = audio_media.get("channels", []) channels = audio_media.get("channels", [])
logger.debug(f"📡 Genesys audio config: format={audio_media.get('format')}, channels={channels}, rate={audio_media.get('rate')}") logger.debug(
f"📡 Genesys audio config: format={audio_media.get('format')}, channels={channels}, rate={audio_media.get('rate')}"
)
# channels is a list like ["external"] or ["external", "internal"] # channels is a list like ["external"] or ["external", "internal"]
if isinstance(channels, list): if isinstance(channels, list):
if "external" in channels and "internal" in channels: if "external" in channels and "internal" in channels:
@@ -795,47 +800,49 @@ class GenesysAudioHookSerializer(FrameSerializer):
elif "internal" in channels: elif "internal" in channels:
self._params.channel = AudioHookChannel.INTERNAL self._params.channel = AudioHookChannel.INTERNAL
logger.debug("📡 Mono mode: internal channel") logger.debug("📡 Mono mode: internal channel")
# Log participant info for debugging # Log participant info for debugging
ani = self._participant.get("ani", "unknown") if self._participant else "unknown" ani = self._participant.get("ani", "unknown") if self._participant else "unknown"
logger.info( logger.info(
f"AudioHook open request: session={self._session_id}, " f"AudioHook open request: session={self._session_id}, "
f"conversation={self._conversation_id}, ani={ani}" f"conversation={self._conversation_id}, ani={ani}"
) )
await self._call_event_handler("on_open", message) await self._call_event_handler("on_open", message)
return OutputTransportMessageUrgentFrame(message=self.create_opened_response( return OutputTransportMessageUrgentFrame(
start_paused=self._params.start_paused, message=self.create_opened_response(
supported_languages=self._params.supported_languages, start_paused=self._params.start_paused,
selected_language=self._params.selected_language supported_languages=self._params.supported_languages,
)) selected_language=self._params.selected_language,
)
)
async def _handle_close(self, message: Dict[str, Any]) -> Frame | None: async def _handle_close(self, message: Dict[str, Any]) -> Frame | None:
"""Handle a 'close' message from Genesys. """Handle a 'close' message from Genesys.
Automatically responds with a 'closed' message. If output_variables Automatically responds with a 'closed' message. If output_variables
were set via set_output_variables(), they will be included in the were set via set_output_variables(), they will be included in the
response and made available in the Architect flow. response and made available in the Architect flow.
Args: Args:
message: The close message from Genesys. message: The close message from Genesys.
Returns: Returns:
OutputTransportMessageUrgentFrame with the closed response OutputTransportMessageUrgentFrame with the closed response
(includes outputVariables if set). (includes outputVariables if set).
""" """
params = message.get("parameters", {}) params = message.get("parameters", {})
reason = params.get("reason", "unknown") reason = params.get("reason", "unknown")
logger.info(f"🔴 Genesys closed the connection: {reason}") logger.info(f"🔴 Genesys closed the connection: {reason}")
self._is_open = False self._is_open = False
logger.info(f"Sending closed response to Genesys...") logger.info(f"Sending closed response to Genesys...")
await self._call_event_handler("on_close", message) await self._call_event_handler("on_close", message)
# Return as urgent frame to be sent through pipeline immediately # Return as urgent frame to be sent through pipeline immediately
# Include any output variables that were set during the session # Include any output variables that were set during the session
return OutputTransportMessageUrgentFrame( return OutputTransportMessageUrgentFrame(
@@ -844,12 +851,12 @@ class GenesysAudioHookSerializer(FrameSerializer):
async def _handle_ping(self, message: Dict[str, Any]) -> Frame | None: async def _handle_ping(self, message: Dict[str, Any]) -> Frame | None:
"""Handle a 'ping' message from Genesys. """Handle a 'ping' message from Genesys.
Automatically responds with a 'pong' message to maintain the connection. Automatically responds with a 'pong' message to maintain the connection.
Args: Args:
message: The ping message from Genesys. message: The ping message from Genesys.
Returns: Returns:
OutputTransportMessageUrgentFrame with pong response. OutputTransportMessageUrgentFrame with pong response.
""" """
@@ -862,92 +869,92 @@ class GenesysAudioHookSerializer(FrameSerializer):
async def _handle_pause(self, message: Dict[str, Any]) -> Frame | None: async def _handle_pause(self, message: Dict[str, Any]) -> Frame | None:
"""Handle a 'pause' message from Genesys. """Handle a 'pause' message from Genesys.
This is used when audio streaming is temporarily suspended This is used when audio streaming is temporarily suspended
(e.g., during hold). (e.g., during hold).
Args: Args:
message: The pause message. message: The pause message.
Returns: Returns:
None (response should be sent via create_resumed_response()). None (response should be sent via create_resumed_response()).
""" """
params = message.get("parameters", {}) params = message.get("parameters", {})
reason = params.get("reason", "unknown") reason = params.get("reason", "unknown")
logger.info(f"AudioHook pause request: reason={reason}") logger.info(f"AudioHook pause request: reason={reason}")
self._is_paused = True self._is_paused = True
await self._call_event_handler("on_pause", message) await self._call_event_handler("on_pause", message)
# Note: Application should call create_resumed_response() when ready # Note: Application should call create_resumed_response() when ready
return None return None
async def _handle_update(self, message: Dict[str, Any]) -> Frame | None: async def _handle_update(self, message: Dict[str, Any]) -> Frame | None:
"""Handle an 'update' message from Genesys. """Handle an 'update' message from Genesys.
Updates may include changes to participants or configuration. Updates may include changes to participants or configuration.
Args: Args:
message: The update message. message: The update message.
Returns: Returns:
None. None.
""" """
params = message.get("parameters", {}) params = message.get("parameters", {})
if "participant" in params: if "participant" in params:
self._participant = params["participant"] self._participant = params["participant"]
logger.debug(f"AudioHook update received: {params}") logger.debug(f"AudioHook update received: {params}")
await self._call_event_handler("on_update", message) await self._call_event_handler("on_update", message)
return None return None
async def _handle_error(self, message: Dict[str, Any]) -> Frame | None: async def _handle_error(self, message: Dict[str, Any]) -> Frame | None:
"""Handle an 'error' message from Genesys. """Handle an 'error' message from Genesys.
Args: Args:
message: The error message. message: The error message.
Returns: Returns:
None. None.
""" """
params = message.get("parameters", {}) params = message.get("parameters", {})
code = params.get("code", 0) code = params.get("code", 0)
error_msg = params.get("message", "Unknown error") error_msg = params.get("message", "Unknown error")
logger.error(f"AudioHook error from Genesys: {code} - {error_msg}") logger.error(f"AudioHook error from Genesys: {code} - {error_msg}")
await self._call_event_handler("on_error", message) await self._call_event_handler("on_error", message)
return None return None
async def _handle_dtmf(self, message: Dict[str, Any]) -> Frame | None: async def _handle_dtmf(self, message: Dict[str, Any]) -> Frame | None:
"""Handle a 'dtmf' message from Genesys. """Handle a 'dtmf' message from Genesys.
DTMF (Dual-Tone Multi-Frequency) events are sent when the user DTMF (Dual-Tone Multi-Frequency) events are sent when the user
presses keys on their phone keypad. presses keys on their phone keypad.
Args: Args:
message: The DTMF message. message: The DTMF message.
Returns: Returns:
InputDTMFFrame with the pressed digit. InputDTMFFrame with the pressed digit.
""" """
params = message.get("parameters", {}) params = message.get("parameters", {})
digit = params.get("digit", "") digit = params.get("digit", "")
if not digit: if not digit:
logger.warning("DTMF message received without digit") logger.warning("DTMF message received without digit")
return None return None
logger.info(f"DTMF received: {digit}") logger.info(f"DTMF received: {digit}")
await self._call_event_handler("on_dtmf", message) await self._call_event_handler("on_dtmf", message)
try: try:
return InputDTMFFrame(KeypadEntry(digit)) return InputDTMFFrame(KeypadEntry(digit))
except ValueError: except ValueError: