From aad93717362b5966193742e0103ba0d2c2d1bf7c Mon Sep 17 00:00:00 2001 From: a692570 Date: Wed, 3 Jun 2026 11:35:35 -0700 Subject: [PATCH] Add Telnyx messaging channel --- changelog/13171.feature.md | 1 + docs/docs/connectors/telnyx.mdx | 68 ++++++++++++ docs/sidebars.js | 1 + rasa/core/channels/__init__.py | 2 + rasa/core/channels/telnyx.py | 158 ++++++++++++++++++++++++++++ tests/core/channels/test_telnyx.py | 161 +++++++++++++++++++++++++++++ 6 files changed, 391 insertions(+) create mode 100644 changelog/13171.feature.md create mode 100644 docs/docs/connectors/telnyx.mdx create mode 100644 rasa/core/channels/telnyx.py create mode 100644 tests/core/channels/test_telnyx.py diff --git a/changelog/13171.feature.md b/changelog/13171.feature.md new file mode 100644 index 000000000000..56fb5d18453c --- /dev/null +++ b/changelog/13171.feature.md @@ -0,0 +1 @@ +Added a Telnyx Messaging channel connector for SMS and MMS assistants. diff --git a/docs/docs/connectors/telnyx.mdx b/docs/docs/connectors/telnyx.mdx new file mode 100644 index 000000000000..660408d0bcb9 --- /dev/null +++ b/docs/docs/connectors/telnyx.mdx @@ -0,0 +1,68 @@ +--- +id: telnyx +sidebar_label: Telnyx +title: Telnyx +description: Deploy a Rasa assistant through SMS or MMS via the Telnyx connector +--- + +You can use the Telnyx connector to deploy an assistant that is available over SMS +or MMS. + +## Getting Credentials + +You need a Telnyx account, a Telnyx API key, and a Telnyx phone number that is +configured for messaging. + +1. Create a Telnyx API key in the Telnyx Mission Control Portal. +2. Buy or select a messaging-capable Telnyx phone number. +3. Assign the phone number to a Messaging Profile. +4. Configure the Messaging Profile inbound webhook URL to point to your Rasa + server. The webhook URL should be + `https://:/webhooks/telnyx/webhook`, replacing the host and port + with the values from your running Rasa server. + +For more information, see the Telnyx documentation for +[receiving messages](https://developers.telnyx.com/docs/messaging/messages/receive-message) +and [sending messages](https://developers.telnyx.com/api-reference/messages/send-a-message). + +## Running on Telnyx + +Add the Telnyx credentials to your `credentials.yml`: + +```yaml-rasa +telnyx: + api_key: "KEY..." + from_number: "+15551234567" +``` + +Restart your Rasa server to make the Telnyx webhook endpoint available. + +The Telnyx connector listens for `message.received` webhook events and passes the +message text to your assistant. Bot responses are sent back to the user through +the Telnyx Messages API. + +## Sending MMS + +The connector sends response images as MMS messages by passing image URLs to +Telnyx as `media_urls`. Your image URL must be reachable by Telnyx. + +```yaml-rasa title="domain.yml" +responses: + utter_show_image: + - text: "Here is the image." + image: "https://example.com/image.png" +``` + +## Custom Payloads + +You can send Telnyx-specific message payloads through a custom response. The +connector fills in `from` and `to` if they are not included. + +```yaml-rasa title="domain.yml" +responses: + utter_custom_telnyx_message: + - custom: + text: "Choose an option" + media_urls: + - "https://example.com/options.png" +``` diff --git a/docs/sidebars.js b/docs/sidebars.js index d6e7f0841b9a..45776861f91c 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -154,6 +154,7 @@ module.exports = { "connectors/facebook-messenger", "connectors/slack", "connectors/telegram", + "connectors/telnyx", "connectors/twilio", "connectors/hangouts", "connectors/microsoft-bot-framework", diff --git a/rasa/core/channels/__init__.py b/rasa/core/channels/__init__.py index 98a91eddb060..0a4083c74d90 100644 --- a/rasa/core/channels/__init__.py +++ b/rasa/core/channels/__init__.py @@ -20,6 +20,7 @@ from rasa.core.channels.rocketchat import RocketChatInput from rasa.core.channels.slack import SlackInput from rasa.core.channels.telegram import TelegramInput +from rasa.core.channels.telnyx import TelnyxInput from rasa.core.channels.twilio import TwilioInput from rasa.core.channels.twilio_voice import TwilioVoiceInput from rasa.core.channels.webexteams import WebexTeamsInput @@ -31,6 +32,7 @@ SlackInput, TelegramInput, MattermostInput, + TelnyxInput, TwilioInput, TwilioVoiceInput, RasaChatInput, diff --git a/rasa/core/channels/telnyx.py b/rasa/core/channels/telnyx.py new file mode 100644 index 000000000000..57ac980a3d8a --- /dev/null +++ b/rasa/core/channels/telnyx.py @@ -0,0 +1,158 @@ +import logging +from typing import Any, Awaitable, Callable, Dict, Optional, Text + +import aiohttp +from sanic import Blueprint, response +from sanic.request import Request +from sanic.response import HTTPResponse + +from rasa.core.channels.channel import InputChannel, OutputChannel, UserMessage + +logger = logging.getLogger(__name__) + +TELNYX_MESSAGES_URL = "https://api.telnyx.com/v2/messages" + + +class TelnyxOutput(OutputChannel): + """Output channel for Telnyx Messaging.""" + + @classmethod + def name(cls) -> Text: + """Name of the channel.""" + return "telnyx" + + def __init__(self, api_key: Text, from_number: Text) -> None: + """Create a Telnyx Messaging output channel.""" + self.api_key = api_key + self.from_number = from_number + + async def _send_message(self, message_data: Dict[Text, Any]) -> None: + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + async with aiohttp.ClientSession() as session: + async with session.post( + TELNYX_MESSAGES_URL, headers=headers, json=message_data + ) as resp: + if resp.status >= 400: + logger.error( + "Failed to send Telnyx message. Status: %s. Response: %s", + resp.status, + await resp.text(), + ) + + async def send_text_message( + self, recipient_id: Text, text: Text, **kwargs: Any + ) -> None: + """Sends text messages.""" + for message_part in text.strip().split("\n\n"): + await self._send_message( + { + "from": self.from_number, + "to": recipient_id, + "text": message_part, + } + ) + + async def send_image_url( + self, recipient_id: Text, image: Text, **kwargs: Any + ) -> None: + """Sends an image.""" + await self._send_message( + { + "from": self.from_number, + "to": recipient_id, + "text": kwargs.get("text", ""), + "media_urls": [image], + } + ) + + async def send_custom_json( + self, recipient_id: Text, json_message: Dict[Text, Any], **kwargs: Any + ) -> None: + """Sends a custom Telnyx message payload.""" + json_message.setdefault("from", self.from_number) + json_message.setdefault("to", recipient_id) + + await self._send_message(json_message) + + +class TelnyxInput(InputChannel): + """Telnyx Messaging input channel.""" + + @classmethod + def name(cls) -> Text: + """Name of the channel.""" + return "telnyx" + + @classmethod + def from_credentials(cls, credentials: Optional[Dict[Text, Any]]) -> InputChannel: + """Load Telnyx credentials from the credentials file.""" + if not credentials: + cls.raise_missing_credentials_exception() + + return cls(credentials.get("api_key"), credentials.get("from_number")) + + def __init__(self, api_key: Text, from_number: Text) -> None: + """Create a Telnyx Messaging input channel.""" + self.api_key = api_key + self.from_number = from_number + + @staticmethod + def _message_payload(request: Request) -> Dict[Text, Any]: + return (request.json or {}).get("data", {}).get("payload", {}) + + @staticmethod + def _event_type(request: Request) -> Optional[Text]: + return (request.json or {}).get("data", {}).get("event_type") + + @staticmethod + def _sender_id(payload: Dict[Text, Any]) -> Optional[Text]: + return payload.get("from", {}).get("phone_number") + + def blueprint( + self, on_new_message: Callable[[UserMessage], Awaitable[Any]] + ) -> Blueprint: + """Defines the Telnyx webhook routes.""" + telnyx_webhook = Blueprint("telnyx_webhook", __name__) + + @telnyx_webhook.route("/", methods=["GET"]) + async def health(_: Request) -> HTTPResponse: + return response.json({"status": "ok"}) + + @telnyx_webhook.route("/webhook", methods=["POST"]) + async def message(request: Request) -> HTTPResponse: + if self._event_type(request) != "message.received": + return response.text("", status=204) + + payload = self._message_payload(request) + sender_id = self._sender_id(payload) + text = payload.get("text") + + if sender_id and text: + metadata = self.get_metadata(request) + await on_new_message( + UserMessage( + text, + self.get_output_channel(), + sender_id, + input_channel=self.name(), + metadata=metadata, + message_id=payload.get("id"), + ) + ) + else: + logger.debug("Invalid Telnyx message webhook") + + return response.text("", status=204) + + return telnyx_webhook + + def get_metadata(self, request: Request) -> Optional[Dict[Text, Any]]: + """Extract the Telnyx message payload as metadata.""" + return self._message_payload(request) + + def get_output_channel(self) -> OutputChannel: + """Create the Telnyx output channel.""" + return TelnyxOutput(self.api_key, self.from_number) diff --git a/tests/core/channels/test_telnyx.py b/tests/core/channels/test_telnyx.py new file mode 100644 index 000000000000..aed16ebd64ef --- /dev/null +++ b/tests/core/channels/test_telnyx.py @@ -0,0 +1,161 @@ +import logging +from http import HTTPStatus +from typing import Any, Dict, List, Text + +import pytest +from sanic import Sanic + +from rasa.core.channels.channel import UserMessage +from rasa.core.channels.telnyx import TelnyxInput, TelnyxOutput + +logger = logging.getLogger(__name__) + + +def telnyx_message_webhook(text: Text = "Hello") -> Dict[Text, Any]: + """Create a Telnyx inbound message webhook payload.""" + return { + "data": { + "event_type": "message.received", + "payload": { + "id": "message-id", + "from": {"phone_number": "+15551234567"}, + "to": [{"phone_number": "+15557654321"}], + "text": text, + }, + } + } + + +@pytest.mark.asyncio +async def test_telnyx_health(): + """Telnyx channel exposes a health route.""" + input_channel = TelnyxInput( + api_key="TELNYX_API_KEY", + from_number="+15557654321", + ) + + async def on_new_message(message: UserMessage) -> None: + pass + + app = Sanic("telnyx_health_test_app") + app.blueprint( + input_channel.blueprint(on_new_message), url_prefix="/webhooks/telnyx" + ) + + _, res = await app.asgi_client.get("/webhooks/telnyx/") + + assert res.status == HTTPStatus.OK + assert res.json == {"status": "ok"} + + +@pytest.mark.asyncio +async def test_telnyx_receive_message(): + """Telnyx message webhooks are passed to Rasa.""" + input_channel = TelnyxInput( + api_key="TELNYX_API_KEY", + from_number="+15557654321", + ) + messages: List[UserMessage] = [] + + async def on_new_message(message: UserMessage) -> None: + messages.append(message) + + app = Sanic("telnyx_test_app") + app.blueprint( + input_channel.blueprint(on_new_message), url_prefix="/webhooks/telnyx" + ) + + _, res = await app.asgi_client.post( + "/webhooks/telnyx/webhook", + json=telnyx_message_webhook(), + ) + + assert res.status == HTTPStatus.NO_CONTENT + assert len(messages) == 1 + assert messages[0].text == "Hello" + assert messages[0].sender_id == "+15551234567" + assert messages[0].input_channel == "telnyx" + assert messages[0].message_id == "message-id" + assert isinstance(messages[0].output_channel, TelnyxOutput) + assert messages[0].metadata == telnyx_message_webhook()["data"]["payload"] + + +@pytest.mark.asyncio +async def test_telnyx_ignores_non_message_received_webhook(): + """Telnyx webhooks for other events are ignored.""" + input_channel = TelnyxInput( + api_key="TELNYX_API_KEY", + from_number="+15557654321", + ) + messages: List[UserMessage] = [] + + async def on_new_message(message: UserMessage) -> None: + messages.append(message) + + app = Sanic("telnyx_ignored_event_test_app") + app.blueprint( + input_channel.blueprint(on_new_message), url_prefix="/webhooks/telnyx" + ) + + _, res = await app.asgi_client.post( + "/webhooks/telnyx/webhook", + json={ + "data": { + "event_type": "message.finalized", + "payload": {"id": "message-id"}, + } + }, + ) + + assert res.status == HTTPStatus.NO_CONTENT + assert messages == [] + + +@pytest.mark.asyncio +async def test_telnyx_send_text_message(monkeypatch): + """Telnyx text messages are sent through the Messages API payload.""" + sent_messages = [] + output_channel = TelnyxOutput("TELNYX_API_KEY", "+15557654321") + + async def mock_send_message(message_data: Dict[Text, Any]) -> None: + sent_messages.append(message_data) + + monkeypatch.setattr(output_channel, "_send_message", mock_send_message) + + await output_channel.send_text_message("+15551234567", "hello\n\nagain") + + assert sent_messages == [ + { + "from": "+15557654321", + "to": "+15551234567", + "text": "hello", + }, + { + "from": "+15557654321", + "to": "+15551234567", + "text": "again", + }, + ] + + +@pytest.mark.asyncio +async def test_telnyx_send_image_url(monkeypatch): + """Telnyx image messages are sent as MMS media URLs.""" + sent_messages = [] + output_channel = TelnyxOutput("TELNYX_API_KEY", "+15557654321") + + async def mock_send_message(message_data: Dict[Text, Any]) -> None: + sent_messages.append(message_data) + + monkeypatch.setattr(output_channel, "_send_message", mock_send_message) + + await output_channel.send_image_url("+15551234567", "https://example.com/image.png") + + assert sent_messages == [ + { + "from": "+15557654321", + "to": "+15551234567", + "text": "", + "media_urls": ["https://example.com/image.png"], + } + ]