Add auto_hang_up to Telnyx serializer

This commit is contained in:
Mark Backman
2025-04-23 13:29:54 -04:00
parent b91780ced2
commit 6bd821ac9a
5 changed files with 110 additions and 13 deletions

View File

@@ -5,6 +5,14 @@ All notable changes to **Pipecat** will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Added automatic hangup logic to the Telnyx serializer. This feature hangs up
the Telnyx call when an `EndFrame` or `CancelFrame` is received. It is
enabled by default and is configurable via the `auto_hang_up` `InputParam`.
## [0.0.64] - 2025-04-22 ## [0.0.64] - 2025-04-22
### Added ### Added

View File

@@ -63,20 +63,35 @@ This project is a FastAPI-based chatbot that integrates with Telnyx to handle We
ngrok http 8765 ngrok http 8765
``` ```
2. **Update the Telnyx TeXML applications Webhook**: 2. **Purchase a number**
- Go to your TeXML configuration page If you haven't already, purchase a number from Telnyx.
- Provide the ngrok URL to the Webhook URL field and ensure the POST method is selected
- Click Save at the bottom of the page
3. **Configure streams.xml**: - Log in to the Telnyx developer portal: https://portal.telnyx.com/
- Buy a number: https://portal.telnyx.com/#/numbers/buy-numbers
3. **Update the Telnyx TeXML applications Webhook**:
- Go to your TeXML configuration page: https://portal.telnyx.com/#/call-control/texml
- Create a new TeXML app, if one doesn't exist already:
- Add an application name
- Under Webhooks, select POST as the "Voice Method"
- Select "Custom URL" under Webhook URL Method
- Enter your ngrok URL in the "Webhook URL" field (e.g. https://your-name.ngrok.io)
- Click "Create" to save
Note: You'll see subsequent pages to set up SIP and Outbound, both are not required, so just skip.
- Navigate to "Manage Numbers" (https://portal.telnyx.com/#/numbers/my-numbers) and under SIP connection, select the pencil icon to edit and select the TeXML application that you just created.
Now your number is ready to call.
4. **Configure streams.xml**:
- Copy the template file to create your local version: - Copy the template file to create your local version:
```sh ```sh
cp templates/streams.xml.template templates/streams.xml cp templates/streams.xml.template templates/streams.xml
``` ```
- In `templates/streams.xml`, replace `<your server url>` with your ngrok URL (without `https://`) - In `templates/streams.xml`, replace `<your server url>` with your ngrok URL (without `https://`)
- The final URL should look like: `wss://abc123.ngrok.io/ws` - The final URL should look like: `wss://abc123.ngrok.io/ws`. This needs to be the same URL that you added to your TeXML app above.
- The encoding (`bidirectionalCodec`) should be `PCMU` or `PCMA` depending on your needs. Based on selected encoding, set the outbound_encoding in `server.py` when the bot is initialized. - The encoding (`bidirectionalCodec`) should be `PCMU` or `PCMA` depending on your needs. Based on selected encoding, set the outbound_encoding in `server.py` when the bot is initialized. (No changes are required by default.)
- The inbound encoding can be controlled from the application configuration for inbound calls and dial/transfer commands for outbound calls. - The inbound encoding can be controlled from the application configuration for inbound calls and dial/transfer commands for outbound calls.
## Running the Application ## Running the Application

View File

@@ -33,9 +33,18 @@ logger.add(sys.stderr, level="DEBUG")
async def run_bot( async def run_bot(
websocket_client, websocket_client,
stream_id: str, stream_id: str,
call_control_id: str,
outbound_encoding: str, outbound_encoding: str,
inbound_encoding: str, inbound_encoding: str,
): ):
serializer = TelnyxFrameSerializer(
stream_id=stream_id,
outbound_encoding=outbound_encoding,
inbound_encoding=inbound_encoding,
call_control_id=call_control_id,
api_key=os.getenv("TELNYX_API_KEY"),
)
transport = FastAPIWebsocketTransport( transport = FastAPIWebsocketTransport(
websocket=websocket_client, websocket=websocket_client,
params=FastAPIWebsocketParams( params=FastAPIWebsocketParams(
@@ -44,7 +53,7 @@ async def run_bot(
vad_enabled=True, vad_enabled=True,
vad_analyzer=SileroVADAnalyzer(), vad_analyzer=SileroVADAnalyzer(),
vad_audio_passthrough=True, vad_audio_passthrough=True,
serializer=TelnyxFrameSerializer(stream_id, outbound_encoding, inbound_encoding), serializer=serializer,
), ),
) )

View File

@@ -37,9 +37,10 @@ async def websocket_endpoint(websocket: WebSocket):
call_data = json.loads(await start_data.__anext__()) call_data = json.loads(await start_data.__anext__())
print(call_data, flush=True) print(call_data, flush=True)
stream_id = call_data["stream_id"] stream_id = call_data["stream_id"]
call_control_id = call_data["start"]["call_control_id"]
outbound_encoding = call_data["start"]["media_format"]["encoding"] outbound_encoding = call_data["start"]["media_format"]["encoding"]
print("WebSocket connection accepted") print("WebSocket connection accepted")
await run_bot(websocket, stream_id, outbound_encoding, "PCMU") await run_bot(websocket, stream_id, call_control_id, outbound_encoding, "PCMU")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -8,6 +8,8 @@ import base64
import json import json
from typing import Optional from typing import Optional
import aiohttp
from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
from pipecat.audio.utils import ( from pipecat.audio.utils import (
@@ -19,6 +21,8 @@ from pipecat.audio.utils import (
) )
from pipecat.frames.frames import ( from pipecat.frames.frames import (
AudioRawFrame, AudioRawFrame,
CancelFrame,
EndFrame,
Frame, Frame,
InputAudioRawFrame, InputAudioRawFrame,
InputDTMFFrame, InputDTMFFrame,
@@ -35,23 +39,39 @@ class TelnyxFrameSerializer(FrameSerializer):
sample_rate: Optional[int] = None # Pipeline input rate sample_rate: Optional[int] = None # Pipeline input rate
inbound_encoding: str = "PCMU" inbound_encoding: str = "PCMU"
outbound_encoding: str = "PCMU" outbound_encoding: str = "PCMU"
auto_hang_up: bool = True
def __init__( def __init__(
self, self,
stream_id: str, stream_id: str,
outbound_encoding: str, outbound_encoding: str,
inbound_encoding: str, inbound_encoding: str,
call_control_id: Optional[str] = None,
api_key: Optional[str] = None,
params: InputParams = InputParams(), params: InputParams = InputParams(),
): ):
"""Initialize the TelnyxFrameSerializer.
Args:
stream_id: The Stream ID for Telnyx.
outbound_encoding: The encoding type for outbound audio (e.g., "PCMU").
inbound_encoding: The encoding type for inbound audio (e.g., "PCMU").
call_control_id: The Call Control ID for the Telnyx call.
api_key: Your Telnyx API key.
params: Configuration parameters.
"""
self._stream_id = stream_id self._stream_id = stream_id
params.outbound_encoding = outbound_encoding params.outbound_encoding = outbound_encoding
params.inbound_encoding = inbound_encoding params.inbound_encoding = inbound_encoding
self._call_control_id = call_control_id
self._api_key = api_key
self._params = params self._params = params
self._telnyx_sample_rate = self._params.telnyx_sample_rate self._telnyx_sample_rate = self._params.telnyx_sample_rate
self._sample_rate = 0 # Pipeline input rate self._sample_rate = 0 # Pipeline input rate
self._resampler = create_default_resampler() self._resampler = create_default_resampler()
self._hangup_attempted = False
@property @property
def type(self) -> FrameSerializerType: def type(self) -> FrameSerializerType:
@@ -61,7 +81,18 @@ class TelnyxFrameSerializer(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
async def serialize(self, frame: Frame) -> str | bytes | None: async def serialize(self, frame: Frame) -> str | bytes | None:
if isinstance(frame, AudioRawFrame): if (
self._params.auto_hang_up
and not self._hangup_attempted
and isinstance(frame, (EndFrame, CancelFrame))
):
self._hangup_attempted = True
await self._hang_up_call()
return None
elif isinstance(frame, StartInterruptionFrame):
answer = {"event": "clear"}
return json.dumps(answer)
elif isinstance(frame, AudioRawFrame):
data = frame.audio data = frame.audio
# Output: Convert PCM at frame's rate to 8kHz encoded for Telnyx # Output: Convert PCM at frame's rate to 8kHz encoded for Telnyx
@@ -84,9 +115,42 @@ class TelnyxFrameSerializer(FrameSerializer):
return json.dumps(answer) return json.dumps(answer)
if isinstance(frame, StartInterruptionFrame): # Return None for unhandled frames
answer = {"event": "clear"} return None
return json.dumps(answer)
async def _hang_up_call(self):
"""Hang up the Telnyx call using Telnyx's REST API."""
try:
call_control_id = self._call_control_id
api_key = self._api_key
if not call_control_id or not api_key:
logger.warning(
"Cannot hang up Telnyx call: call_control_id and api_key must be provided"
)
return
# Telnyx API endpoint for hanging up a call
endpoint = f"https://api.telnyx.com/v2/calls/{call_control_id}/actions/hangup"
# Set headers with API key
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
# Make the POST request to hang up the call
async with aiohttp.ClientSession() as session:
async with session.post(endpoint, headers=headers) as response:
if response.status == 200:
logger.info(f"Successfully terminated Telnyx call {call_control_id}")
else:
# Get the error details for better debugging
error_text = await response.text()
logger.error(
f"Failed to terminate Telnyx call {call_control_id}: "
f"Status {response.status}, Response: {error_text}"
)
except Exception as e:
logger.exception(f"Failed to hang up Telnyx call: {e}")
async def deserialize(self, data: str | bytes) -> Frame | None: async def deserialize(self, data: str | bytes) -> Frame | None:
message = json.loads(data) message = json.loads(data)