diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/.env.example b/examples/getting-started/conversation/send_handle_incoming_sms/.env.example new file mode 100644 index 0000000..9716be1 --- /dev/null +++ b/examples/getting-started/conversation/send_handle_incoming_sms/.env.example @@ -0,0 +1,16 @@ +# Sinch credentials (from dashboard.sinch.com → Access Keys) +SINCH_PROJECT_ID= +SINCH_KEY_ID= +SINCH_KEY_SECRET= + +# Conversation API: existing app (already created and configured for SMS). +# SINCH_CONVERSATION_REGION is required. +# Set it to the same region as the one your app was created in (e.g. eu). +CONVERSATION_APP_ID= +SINCH_CONVERSATION_REGION= + +# Webhook secret (set when configuring the callback in Sinch dashboard) +CONVERSATION_WEBHOOKS_SECRET= + +# Server +SERVER_PORT=3001 diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/README.md b/examples/getting-started/conversation/send_handle_incoming_sms/README.md new file mode 100644 index 0000000..ee2fda8 --- /dev/null +++ b/examples/getting-started/conversation/send_handle_incoming_sms/README.md @@ -0,0 +1,100 @@ +# Getting Started: Receive Mobile-originated (MO) SMS and send Mobile-terminated (MT) reply (Conversation API) + + +This directory contains a small server built with the [Sinch Python SDK](https://github.com/sinch/sinch-sdk-python) +that receives mobile-originated (MO) SMS on your Sinch number and sends a mobile-terminated (MT) SMS back +to the same phone. The reply echoes the incoming text (e.g. *"Your message said: <content of MO>"*) so you can +see that the MO was received and processed. + + + +## Requirements + +- [Python 3.9+](https://www.python.org/) +- [Flask](https://flask.palletsprojects.com/en/stable/) +- [Sinch account](https://dashboard.sinch.com/) +- An existing Conversation API app configured for SMS (with a Sinch number) +- [ngrok](https://ngrok.com/docs) (or similar) to expose your local server +- [Poetry](https://python-poetry.org/) + +## Configuration + +1. **Environment variables** + Copy [.env.example](.env.example) to `.env` in this directory, then set your credentials and app settings. + + - Sinch credentials (from the Sinch dashboard, Access Keys): + ``` + SINCH_PROJECT_ID=your_project_id + SINCH_KEY_ID=your_key_id + SINCH_KEY_SECRET=your_key_secret + ``` + + - Conversation API app (existing app, already configured for SMS). Set `SINCH_CONVERSATION_REGION` to the same region as the one your app was created in (e.g. `eu`): + ``` + CONVERSATION_APP_ID=your_conversation_app_id + SINCH_CONVERSATION_REGION= + ``` + + - Webhook secret (the value you set when configuring the callback URL for this app). + See [Conversation API callbacks](https://developers.sinch.com/docs/conversation/callbacks): + ``` + CONVERSATION_WEBHOOKS_SECRET=your_webhook_secret + ``` + + - Server port (optional; default 3001): + ``` + SERVER_PORT=3001 + ``` + +2. **Install dependencies** + From this directory: + ```bash + poetry install + ``` + Install the Sinch SDK from the **repository root**: `pip install -e .` (recommended when developing from this repo). + Alternatively, install with pip: `flask`, `python-dotenv`, and `sinch` (e.g. from PyPI). + +## Usage + +### Running the server + +1. Navigate to this directory: + ``` + cd examples/getting-started/conversation/send_handle_incoming_sms + ``` + + +2. Start the server: + ```bash + poetry run python server.py + ``` + Or run it directly: + ```bash + python server.py + ``` + +The server listens on the port set in your `.env` file (default: 3001). + +### Exposing the server with ngrok + +To receive webhooks on your machine, expose the server with a tunnel (e.g. ngrok). + + +```bash +ngrok http 3001 +``` + +You will see output similar to: +``` +Forwarding https://abc123.ngrok-free.app -> http://localhost:3001 +``` + +Use the **HTTPS** URL when configuring the callback: +`https:///ConversationEvent` + +Configure this callback URL (and the webhook secret) in the Sinch dashboard for your Conversation API app. +The webhook secret must match `CONVERSATION_WEBHOOKS_SECRET` in your `.env`. + +### Sending an SMS to your Sinch number + +Send an SMS from your phone to the **Sinch number** linked to your Conversation API app. You should receive the echo reply on your phone. diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/controller.py b/examples/getting-started/conversation/send_handle_incoming_sms/controller.py new file mode 100644 index 0000000..bc1d717 --- /dev/null +++ b/examples/getting-started/conversation/send_handle_incoming_sms/controller.py @@ -0,0 +1,36 @@ +from flask import request, Response +from server_business_logic import handle_conversation_event + + +class ConversationController: + def __init__(self, sinch_client, webhooks_secret, app_id): + self.sinch_client = sinch_client + self.webhooks_secret = webhooks_secret + self.app_id = app_id + self.logger = self.sinch_client.configuration.logger + + def conversation_event(self): + headers = dict(request.headers) + raw_body = getattr(request, "raw_body", None) or b"" + + webhooks_service = self.sinch_client.conversation.webhooks(self.webhooks_secret) + + # Set to True to enforce signature validation (recommended in production) + ensure_valid_signature = False + if ensure_valid_signature: + valid = webhooks_service.validate_authentication_header( + headers=headers, + json_payload=raw_body, + ) + if not valid: + return Response(status=401) + + event = webhooks_service.parse_event(raw_body, headers) + handle_conversation_event( + event=event, + logger=self.logger, + sinch_client=self.sinch_client, + app_id=self.app_id, + ) + + return Response(status=200) diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/pyproject.toml b/examples/getting-started/conversation/send_handle_incoming_sms/pyproject.toml new file mode 100644 index 0000000..7d9661e --- /dev/null +++ b/examples/getting-started/conversation/send_handle_incoming_sms/pyproject.toml @@ -0,0 +1,15 @@ +[tool.poetry] +name = "sinch-getting-started-send-handle-incoming-sms" +version = "0.1.0" +description = "Getting Started: send and handle incoming SMS with Conversation API (DISPATCH, channel identity)" +readme = "README.md" +package-mode = false + +[tool.poetry.dependencies] +python = "^3.9" +python-dotenv = "^1.0.0" +flask = "^3.0.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/server.py b/examples/getting-started/conversation/send_handle_incoming_sms/server.py new file mode 100644 index 0000000..6582205 --- /dev/null +++ b/examples/getting-started/conversation/send_handle_incoming_sms/server.py @@ -0,0 +1,62 @@ +import logging +from pathlib import Path + + +from flask import Flask, request +from dotenv import dotenv_values + +from sinch import SinchClient +from controller import ConversationController + +app = Flask(__name__) + + +def load_config(): + current_dir = Path(__file__).resolve().parent + env_file = current_dir / ".env" + if not env_file.exists(): + raise FileNotFoundError(f"Missing .env in {current_dir}. Copy from .env.example.") + return dict(dotenv_values(env_file)) + + +config = load_config() +port = int(config.get("SERVER_PORT") or "3001") +app_id = config.get("CONVERSATION_APP_ID") or "" +webhooks_secret = config.get("CONVERSATION_WEBHOOKS_SECRET") or "" +conversation_region = (config.get("SINCH_CONVERSATION_REGION") or "").strip() +if not conversation_region: + raise ValueError( + "SINCH_CONVERSATION_REGION is required in .env to provide all parameters needed for Conversation API requests. " + "Set it to the same region as the one your Conversation API app was created in (e.g. eu)." + ) + +sinch_client = SinchClient( + project_id=config.get("SINCH_PROJECT_ID", ""), + key_id=config.get("SINCH_KEY_ID", ""), + key_secret=config.get("SINCH_KEY_SECRET", ""), + conversation_region=conversation_region, +) +logging.basicConfig() +sinch_client.configuration.logger.setLevel(logging.INFO) + +conversation_controller = ConversationController( + sinch_client, webhooks_secret, app_id +) + + +@app.before_request +def before_request(): + request.raw_body = request.get_data() + + +app.add_url_rule( + "/ConversationEvent", + methods=["POST"], + view_func=conversation_controller.conversation_event, +) + +if __name__ == "__main__": + print("Getting Started: MO SMS → MT reply (Conversation API, DISPATCH, channel identity)") + print(f"App ID: {app_id or '(set CONVERSATION_APP_ID in .env)'}") + print(f"Listening on port {port}. Expose with: ngrok http {port}") + app.run(port=port) diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py b/examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py new file mode 100644 index 0000000..506f9f7 --- /dev/null +++ b/examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py @@ -0,0 +1,52 @@ +""" +On inbound SMS (MO), send a reply (MT) to the same number: "Your message said: ". +Uses channel identity (SMS + phone number) only; app is in DISPATCH mode. +""" + +from sinch.domains.conversation.models.v1.webhooks import MessageInboundEvent + + +def handle_conversation_event(event, logger, sinch_client, app_id): + """Webhook entry: handle only MESSAGE_INBOUND; delegate to inbound handler.""" + if not isinstance(event, MessageInboundEvent): + return + _handle_message_inbound(event, logger, sinch_client, app_id) + + +def _get_mo_text(event: MessageInboundEvent) -> str: + """Return the inbound message text, or a short placeholder if none.""" + msg = event.message + contact_msg = msg.contact_message + if getattr(contact_msg, "text_message", None): + return contact_msg.text_message.text or "(empty)" + return "(no text content)" + + +def _handle_message_inbound(event: MessageInboundEvent, logger, sinch_client, app_id): + """Parse MO, then send MT echo to the same number via Conversation API.""" + msg = event.message + channel_identity = msg.channel_identity + if not channel_identity: + logger.warning("MESSAGE_INBOUND with no channel_identity") + return + + identity = channel_identity.identity + mo_text = _get_mo_text(event) + logger.info("MO SMS from %s: %s", identity, mo_text) + + if not app_id: + logger.warning("CONVERSATION_APP_ID not set; skipping MT reply.") + return + + reply_text = f"Your message said: {mo_text}" + response = sinch_client.conversation.messages.send_text_message( + app_id=app_id, + text=reply_text, + recipient_identities=[{"channel": "SMS", "identity": identity}], + ) + logger.info("MT reply sent to %s (channel identity): %s", identity, reply_text[:60]) + logger.debug( + "Response: message_id=%s accepted_time=%s", + response.message_id, + response.accepted_time + )