diff --git a/CHANGELOG.md b/CHANGELOG.md index cb7e78aaf..e3b267519 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Added diff --git a/examples/telnyx-chatbot/README.md b/examples/telnyx-chatbot/README.md index e7400940a..9c934cbf0 100644 --- a/examples/telnyx-chatbot/README.md +++ b/examples/telnyx-chatbot/README.md @@ -63,20 +63,35 @@ This project is a FastAPI-based chatbot that integrates with Telnyx to handle We ngrok http 8765 ``` -2. **Update the Telnyx TeXML applications Webhook**: +2. **Purchase a number** - - Go to your TeXML configuration page - - Provide the ngrok URL to the Webhook URL field and ensure the POST method is selected - - Click Save at the bottom of the page + If you haven't already, purchase a number from Telnyx. -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: ```sh cp templates/streams.xml.template templates/streams.xml ``` - In `templates/streams.xml`, replace `` with your ngrok URL (without `https://`) - - The final URL should look like: `wss://abc123.ngrok.io/ws` - - 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 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. (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. ## Running the Application diff --git a/examples/telnyx-chatbot/bot.py b/examples/telnyx-chatbot/bot.py index 861aa1c7a..ff743f26c 100644 --- a/examples/telnyx-chatbot/bot.py +++ b/examples/telnyx-chatbot/bot.py @@ -33,9 +33,18 @@ logger.add(sys.stderr, level="DEBUG") async def run_bot( websocket_client, stream_id: str, + call_control_id: str, outbound_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( websocket=websocket_client, params=FastAPIWebsocketParams( @@ -44,7 +53,7 @@ async def run_bot( vad_enabled=True, vad_analyzer=SileroVADAnalyzer(), vad_audio_passthrough=True, - serializer=TelnyxFrameSerializer(stream_id, outbound_encoding, inbound_encoding), + serializer=serializer, ), ) diff --git a/examples/telnyx-chatbot/server.py b/examples/telnyx-chatbot/server.py index 7bc1c0615..23cb9f065 100644 --- a/examples/telnyx-chatbot/server.py +++ b/examples/telnyx-chatbot/server.py @@ -37,9 +37,10 @@ async def websocket_endpoint(websocket: WebSocket): call_data = json.loads(await start_data.__anext__()) print(call_data, flush=True) stream_id = call_data["stream_id"] + call_control_id = call_data["start"]["call_control_id"] outbound_encoding = call_data["start"]["media_format"]["encoding"] 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__": diff --git a/src/pipecat/serializers/telnyx.py b/src/pipecat/serializers/telnyx.py index cbf16a156..0eb75c753 100644 --- a/src/pipecat/serializers/telnyx.py +++ b/src/pipecat/serializers/telnyx.py @@ -8,6 +8,8 @@ import base64 import json from typing import Optional +import aiohttp +from loguru import logger from pydantic import BaseModel from pipecat.audio.utils import ( @@ -19,6 +21,8 @@ from pipecat.audio.utils import ( ) from pipecat.frames.frames import ( AudioRawFrame, + CancelFrame, + EndFrame, Frame, InputAudioRawFrame, InputDTMFFrame, @@ -35,23 +39,39 @@ class TelnyxFrameSerializer(FrameSerializer): sample_rate: Optional[int] = None # Pipeline input rate inbound_encoding: str = "PCMU" outbound_encoding: str = "PCMU" + auto_hang_up: bool = True def __init__( self, stream_id: str, outbound_encoding: str, inbound_encoding: str, + call_control_id: Optional[str] = None, + api_key: Optional[str] = None, 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 params.outbound_encoding = outbound_encoding params.inbound_encoding = inbound_encoding + self._call_control_id = call_control_id + self._api_key = api_key self._params = params self._telnyx_sample_rate = self._params.telnyx_sample_rate self._sample_rate = 0 # Pipeline input rate self._resampler = create_default_resampler() + self._hangup_attempted = False @property def type(self) -> FrameSerializerType: @@ -61,7 +81,18 @@ class TelnyxFrameSerializer(FrameSerializer): self._sample_rate = self._params.sample_rate or frame.audio_in_sample_rate 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 # Output: Convert PCM at frame's rate to 8kHz encoded for Telnyx @@ -84,9 +115,42 @@ class TelnyxFrameSerializer(FrameSerializer): return json.dumps(answer) - if isinstance(frame, StartInterruptionFrame): - answer = {"event": "clear"} - return json.dumps(answer) + # Return None for unhandled frames + return None + + 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: message = json.loads(data)