diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 388b22b2..afac0688 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,6 +101,7 @@ jobs: cp sinch-sdk-mockserver/features/sms/webhooks.feature ./tests/e2e/sms/features/ cp sinch-sdk-mockserver/features/number-lookup/lookups.feature ./tests/e2e/number-lookup/features/ cp sinch-sdk-mockserver/features/conversation/messages.feature ./tests/e2e/conversation/features/ + cp sinch-sdk-mockserver/features/conversation/webhooks-events.feature ./tests/e2e/conversation/features/ - name: Wait for mock server run: .github/scripts/wait-for-mockserver.sh diff --git a/examples/webhooks/.env.example b/examples/webhooks/.env.example index 02e98c4b..13561254 100644 --- a/examples/webhooks/.env.example +++ b/examples/webhooks/.env.example @@ -6,4 +6,6 @@ SERVER_PORT = # See https://developers.sinch.com/docs/numbers/api-reference/numbers/tag/Numbers-Callbacks/ NUMBERS_WEBHOOKS_SECRET = NUMBERS_WEBHOOKS_SECRET # See https://developers.sinch.com/docs/sms/api-reference/sms/tag/Webhooks/#tag/Webhooks/section/Callbacks -SMS_WEBHOOKS_SECRET = SMS_WEBHOOKS_SECRET \ No newline at end of file +SMS_WEBHOOKS_SECRET = SMS_WEBHOOKS_SECRET +# See https://developers.sinch.com/docs/conversation/callbacks +CONVERSATION_WEBHOOKS_SECRET = CONVERSATION_WEBHOOKS_SECRET \ No newline at end of file diff --git a/examples/webhooks/README.md b/examples/webhooks/README.md index 8d9674c5..c2f88a45 100644 --- a/examples/webhooks/README.md +++ b/examples/webhooks/README.md @@ -6,6 +6,7 @@ to process incoming webhooks from Sinch services. The webhook handlers are organized by service: - **SMS**: Handlers for SMS webhook events (`sms_api/`) - **Numbers**: Handlers for Numbers API webhook events (`numbers_api/`) +- **Conversation**: Handlers for Conversation API webhook events (`conversation_api/`) This directory contains both the webhook handlers and the server application (`server.py`) that uses them. @@ -39,6 +40,10 @@ This directory contains both the webhook handlers and the server application (`s ``` SMS_WEBHOOKS_SECRET=Your Sinch SMS Webhook Secret ``` + - Conversation controller: Set the webhook secret you configured when creating the webhook (see [Conversation API callbacks](https://developers.sinch.com/docs/conversation/callbacks)): + ``` + CONVERSATION_WEBHOOKS_SECRET=Your Conversation Webhook Secret + ``` ## Usage @@ -69,10 +74,11 @@ The server will start on the port specified in your `.env` file (default: 3001). The server exposes the following endpoints: -| Service | Endpoint | -|--------------|--------------------| -| Numbers | /NumbersEvent | -| SMS | /SmsEvent | +| Service | Endpoint | +|--------------|----------------------| +| Numbers | /NumbersEvent | +| SMS | /SmsEvent | +| Conversation | /ConversationEvent | ## Using ngrok to expose your local server @@ -93,10 +99,12 @@ Forwarding https://adbd-79-148-170-158.ngrok-free.app -> http Use the `https` forwarding URL in your callback configuration. For example: - Numbers: https://adbd-79-148-170-158.ngrok-free.app/NumbersEvent - SMS: https://adbd-79-148-170-158.ngrok-free.app/SmsEvent + - Conversation: https://adbd-79-148-170-158.ngrok-free.app/ConversationEvent Use this value to configure the callback URLs: - **Numbers**: Set the `callback_url` parameter when renting or updating a number via the SDK (e.g., `available_numbers_apis` rent/update flow: [rent](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/available_numbers_apis.py#L69), [update](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/available_numbers_apis.py#L89)); you can also update active numbers via `active_numbers_apis` ([example](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/active_numbers_apis.py#L64)). -- **SMS**: Set the `callback_url` parameter when configuring your SMS service plan via the SDK (see `batches_apis` examples: [send/dry-run callbacks](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/sms/api/v1/batches_apis.py#L147), [update/replace callbacks](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/sms/api/v1/batches_apis.py#L491)); you can also set it directly via the SMS API. +- **SMS**: Set the `callback_url` parameter when configuring your SMS service plan via the SDK (see `batches_apis` examples: [send/dry-run callbacks](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/sms/api/v1/batches_apis.py#L146), [update/replace callbacks](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/sms/api/v1/batches_apis.py#L491)); you can also set it directly via the SMS API. +- **Conversation**: Set the `callback_url` parameter when sending a message via the SDK (see `messages_apis` example: [send_text_message](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/conversation/api/v1/messages_apis.py#L420)). You can also set these callback URLs in the Sinch dashboard; the API parameters above override the default values configured there. diff --git a/examples/webhooks/conversation_api/__init__.py b/examples/webhooks/conversation_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/webhooks/conversation_api/controller.py b/examples/webhooks/conversation_api/controller.py new file mode 100644 index 00000000..0e5abb4c --- /dev/null +++ b/examples/webhooks/conversation_api/controller.py @@ -0,0 +1,52 @@ +import re +from flask import request, Response +from webhooks.conversation_api.server_business_logic import handle_conversation_event + + +def _charset_from_content_type(content_type): + """Extract charset from Content-Type header; default to utf-8 if missing.""" + if not content_type: + return "utf-8" + match = re.search(r"charset\s*=\s*([^\s;]+)", content_type, re.I) + return match.group(1).strip("'\"").lower() if match else "utf-8" + + +def _decode_body(raw_body, content_type): + """Decode request body using Content-Type charset, fallback to utf-8.""" + if not raw_body: + return "" + charset = _charset_from_content_type(content_type) + try: + return raw_body.decode(charset) + except (LookupError, UnicodeDecodeError): + return raw_body.decode("utf-8") + + +class ConversationController: + def __init__(self, sinch_client, webhooks_secret): + self.sinch_client = sinch_client + self.webhooks_secret = webhooks_secret + self.logger = self.sinch_client.configuration.logger + + def conversation_event(self): + headers = dict(request.headers) + raw_body = request.raw_body if request.raw_body else b"" + content_type = headers.get("Content-Type") or headers.get("content-type") or "" + body_str = _decode_body(raw_body, content_type) + + 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=body_str, + ) + if not valid: + return Response(status=401) + + event = webhooks_service.parse_event(body_str) + handle_conversation_event(event=event, logger=self.logger) + + return Response(status=200) diff --git a/examples/webhooks/conversation_api/server_business_logic.py b/examples/webhooks/conversation_api/server_business_logic.py new file mode 100644 index 00000000..03ef74e9 --- /dev/null +++ b/examples/webhooks/conversation_api/server_business_logic.py @@ -0,0 +1,81 @@ +from sinch.domains.conversation.models.v1.webhooks import ( + ConversationWebhookEventBase, + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, +) + + +def handle_conversation_event(event: ConversationWebhookEventBase, logger): + """ + Dispatch a Conversation webhook event to the appropriate handler by trigger type. + + :param event: Parsed webhook event (MessageDeliveryReceiptEvent, MessageInboundEvent, etc.). + :param logger: Logger instance for output. + """ + if isinstance(event, MessageInboundEvent): + _handle_message_inbound(event, logger) + elif isinstance(event, MessageDeliveryReceiptEvent): + _handle_message_delivery(event, logger) + elif isinstance(event, MessageSubmitEvent): + _handle_message_submit(event, logger) + else: + logger.debug("Event: %s", event.model_dump_json(indent=2) if hasattr(event, "model_dump_json") else event) + + +def _handle_message_inbound(event: MessageInboundEvent, logger): + """Handle MESSAGE_INBOUND: log inbound message.""" + logger.info("## MESSAGE_INBOUND") + msg = event.message + contact_msg = msg.contact_message + channel_identity = msg.channel_identity + contact_id = msg.contact_id + channel = channel_identity.channel if channel_identity else "?" + identity = channel_identity.identity if channel_identity else "?" + logger.info( + "A new message has been received on the channel '%s' (identity: %s) from the contact ID '%s'", + channel, + identity, + contact_id, + ) + if contact_msg: + if hasattr(contact_msg, "text_message") and contact_msg.text_message: + logger.info("Text: %s", contact_msg.text_message.text) + elif hasattr(contact_msg, "media_message") and contact_msg.media_message: + logger.info("Media: %s", getattr(contact_msg.media_message, "url", contact_msg.media_message)) + elif hasattr(contact_msg, "fallback_message") and contact_msg.fallback_message: + logger.info("Fallback: %s", contact_msg.fallback_message) + else: + logger.info("Contact message: %s", contact_msg) + + +def _handle_message_delivery(event: MessageDeliveryReceiptEvent, logger): + """Handle MESSAGE_DELIVERY: log delivery status and failure reason if failed.""" + logger.info("## MESSAGE_DELIVERY") + report = event.message_delivery_report + status = report.status + logger.info("Message delivery status: '%s'", status) + if status == "FAILED" and report.reason: + logger.info( + "Reason: %s (%s) - %s", + report.reason.code, + getattr(report.reason, "sub_code", ""), + report.reason.description, + ) + + +def _handle_message_submit(event: MessageSubmitEvent, logger): + """Handle MESSAGE_SUBMIT: log that the message was submitted to the channel.""" + logger.info("## MESSAGE_SUBMIT") + submit_notification = event.message_submit_notification + channel_identity = submit_notification.channel_identity + channel = channel_identity.channel if channel_identity else "?" + identity = channel_identity.identity if channel_identity else "?" + logger.info( + "The following message has been submitted on the channel '%s' (identity: %s) to the contact ID '%s'", + channel, + identity, + submit_notification.contact_id, + ) + if submit_notification.submitted_message: + logger.debug("Submitted message: %s", submit_notification.submitted_message) diff --git a/examples/webhooks/server.py b/examples/webhooks/server.py index d7f6f1ca..98caa89a 100644 --- a/examples/webhooks/server.py +++ b/examples/webhooks/server.py @@ -10,6 +10,7 @@ from flask import Flask, request from webhooks.numbers_api.controller import NumbersController from webhooks.sms_api.controller import SmsController +from webhooks.conversation_api.controller import ConversationController from webhooks.sinch_client_helper import get_sinch_client, load_config app = Flask(__name__) @@ -18,6 +19,7 @@ port = int(config.get('SERVER_PORT') or 3001) numbers_webhooks_secret = config.get('NUMBERS_WEBHOOKS_SECRET') sms_webhooks_secret = config.get('SMS_WEBHOOKS_SECRET') +conversation_webhooks_secret = config.get('CONVERSATION_WEBHOOKS_SECRET') sinch_client = get_sinch_client(config) # Set up logging at the INFO level @@ -26,6 +28,7 @@ numbers_controller = NumbersController(sinch_client, numbers_webhooks_secret) sms_controller = SmsController(sinch_client, sms_webhooks_secret) +conversation_controller = ConversationController(sinch_client, conversation_webhooks_secret or '') # Middleware to capture raw body @@ -36,6 +39,7 @@ def before_request(): app.add_url_rule('/NumbersEvent', methods=['POST'], view_func=numbers_controller.numbers_event) app.add_url_rule('/SmsEvent', methods=['POST'], view_func=sms_controller.sms_event) +app.add_url_rule('/ConversationEvent', methods=['POST'], view_func=conversation_controller.conversation_event) if __name__ == '__main__': app.run(port=port) diff --git a/sinch/domains/conversation/conversation.py b/sinch/domains/conversation/conversation.py index 91599d8b..b85cff21 100644 --- a/sinch/domains/conversation/conversation.py +++ b/sinch/domains/conversation/conversation.py @@ -1,6 +1,7 @@ from sinch.domains.conversation.api.v1 import ( Messages, ) +from sinch.domains.conversation.webhooks.v1 import ConversationWebhooks class Conversation: @@ -12,3 +13,14 @@ class Conversation: def __init__(self, sinch): self._sinch = sinch self.messages = Messages(self._sinch) + + def webhooks(self, callback_secret: str) -> ConversationWebhooks: + """ + Create a Conversation API webhooks handler with the given webhook secret. + + :param callback_secret: Secret used for webhook signature validation. + :type callback_secret: str + :returns: A configured webhooks handler. + :rtype: ConversationWebhooks + """ + return ConversationWebhooks(callback_secret) diff --git a/sinch/domains/conversation/models/v1/webhooks/__init__.py b/sinch/domains/conversation/models/v1/webhooks/__init__.py new file mode 100644 index 00000000..5a8208fa --- /dev/null +++ b/sinch/domains/conversation/models/v1/webhooks/__init__.py @@ -0,0 +1,23 @@ +from sinch.domains.conversation.models.v1.webhooks.events import ( + ConversationWebhookEvent, + ConversationWebhookEventBase, + InboundMessage, + MessageDeliveryReceiptEvent, + MessageDeliveryReport, + MessageDeliveryStatusType, + MessageInboundEvent, + MessageSubmitEvent, + MessageSubmitNotification, +) + +__all__ = [ + "ConversationWebhookEvent", + "ConversationWebhookEventBase", + "InboundMessage", + "MessageDeliveryReceiptEvent", + "MessageDeliveryReport", + "MessageDeliveryStatusType", + "MessageInboundEvent", + "MessageSubmitEvent", + "MessageSubmitNotification", +] diff --git a/sinch/domains/conversation/models/v1/webhooks/events/__init__.py b/sinch/domains/conversation/models/v1/webhooks/events/__init__.py new file mode 100644 index 00000000..9f972911 --- /dev/null +++ b/sinch/domains/conversation/models/v1/webhooks/events/__init__.py @@ -0,0 +1,39 @@ +from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event import ( + ConversationWebhookEvent, +) +from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event_base import ( + ConversationWebhookEventBase, +) +from sinch.domains.conversation.models.v1.webhooks.events.inbound_message import ( + InboundMessage, +) +from sinch.domains.conversation.models.v1.webhooks.events.message_delivery_receipt_event import ( + MessageDeliveryReceiptEvent, +) +from sinch.domains.conversation.models.v1.webhooks.events.message_delivery_report import ( + MessageDeliveryReport, +) +from sinch.domains.conversation.models.v1.webhooks.events.message_delivery_status_type import ( + MessageDeliveryStatusType, +) +from sinch.domains.conversation.models.v1.webhooks.events.message_inbound_event import ( + MessageInboundEvent, +) +from sinch.domains.conversation.models.v1.webhooks.events.message_submit_event import ( + MessageSubmitEvent, +) +from sinch.domains.conversation.models.v1.webhooks.events.message_submit_notification import ( + MessageSubmitNotification, +) + +__all__ = [ + "ConversationWebhookEvent", + "ConversationWebhookEventBase", + "InboundMessage", + "MessageDeliveryReceiptEvent", + "MessageDeliveryReport", + "MessageDeliveryStatusType", + "MessageInboundEvent", + "MessageSubmitEvent", + "MessageSubmitNotification", +] diff --git a/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event.py b/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event.py new file mode 100644 index 00000000..8a7d07dd --- /dev/null +++ b/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event.py @@ -0,0 +1,22 @@ +from typing import Union + +from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event_base import ( + ConversationWebhookEventBase, +) +from sinch.domains.conversation.models.v1.webhooks.events.message_delivery_receipt_event import ( + MessageDeliveryReceiptEvent, +) +from sinch.domains.conversation.models.v1.webhooks.events.message_inbound_event import ( + MessageInboundEvent, +) +from sinch.domains.conversation.models.v1.webhooks.events.message_submit_event import ( + MessageSubmitEvent, +) + + +ConversationWebhookEvent = Union[ + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, + ConversationWebhookEventBase, +] diff --git a/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event_base.py b/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event_base.py new file mode 100644 index 00000000..26cf3eea --- /dev/null +++ b/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event_base.py @@ -0,0 +1,35 @@ +from datetime import datetime +from typing import Optional + +from pydantic import Field, StrictStr + +from sinch.domains.conversation.webhooks.v1.internal import WebhookEvent + + +class ConversationWebhookEventBase(WebhookEvent): + """Base fields present on every Conversation API webhook payload.""" + + app_id: Optional[StrictStr] = Field( + default=None, + description="Id of the subscribed app.", + ) + project_id: Optional[StrictStr] = Field( + default=None, + description="The project ID of the app which has subscribed for the callback.", + ) + accepted_time: Optional[datetime] = Field( + default=None, + description="Timestamp when the channel callback was accepted by the Conversation API.", + ) + event_time: Optional[datetime] = Field( + default=None, + description="Timestamp of the event as provided by the underlying channels.", + ) + message_metadata: Optional[StrictStr] = Field( + default=None, + description="Context-dependent metadata.", + ) + correlation_id: Optional[StrictStr] = Field( + default=None, + description="Value from correlation_id of the send message request.", + ) diff --git a/sinch/domains/conversation/models/v1/webhooks/events/inbound_message.py b/sinch/domains/conversation/models/v1/webhooks/events/inbound_message.py new file mode 100644 index 00000000..2b4d45ec --- /dev/null +++ b/sinch/domains/conversation/models/v1/webhooks/events/inbound_message.py @@ -0,0 +1,20 @@ +from typing import Optional + +from pydantic import Field + +from sinch.domains.conversation.webhooks.v1.internal import WebhookEvent +from sinch.domains.conversation.models.v1.messages.shared.message_common_props import ( + MessageCommonProps, +) +from sinch.domains.conversation.models.v1.messages.response.types.contact_message import ( + ContactMessage, +) + + +class InboundMessage(MessageCommonProps, WebhookEvent): + """Inbound message container (contact message + channel/contact info).""" + + contact_message: Optional[ContactMessage] = Field( + default=None, + description="The contact (inbound) message content.", + ) diff --git a/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_receipt_event.py b/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_receipt_event.py new file mode 100644 index 00000000..79ef1a9b --- /dev/null +++ b/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_receipt_event.py @@ -0,0 +1,17 @@ +from pydantic import Field + +from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event_base import ( + ConversationWebhookEventBase, +) +from sinch.domains.conversation.models.v1.webhooks.events.message_delivery_report import ( + MessageDeliveryReport, +) + + +class MessageDeliveryReceiptEvent(ConversationWebhookEventBase): + """Webhook event for MESSAGE_DELIVERY (delivery receipt for app messages).""" + + message_delivery_report: MessageDeliveryReport = Field( + default=None, + description="The delivery report payload.", + ) diff --git a/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_report.py b/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_report.py new file mode 100644 index 00000000..72aa4232 --- /dev/null +++ b/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_report.py @@ -0,0 +1,53 @@ +from typing import Optional + +from pydantic import Field, StrictStr + +from sinch.domains.conversation.webhooks.v1.internal import WebhookEvent +from sinch.domains.conversation.models.v1.messages.shared import ( + ChannelIdentity, + Reason, +) +from sinch.domains.conversation.models.v1.messages.types.processing_mode_type import ( + ProcessingModeType, +) + +from sinch.domains.conversation.models.v1.webhooks.events.message_delivery_status_type import ( + MessageDeliveryStatusType, +) + + +class MessageDeliveryReport(WebhookEvent): + """Delivery report for an app message (MESSAGE_DELIVERY trigger).""" + + message_id: Optional[StrictStr] = Field( + default=None, + description="The ID of the message.", + ) + conversation_id: Optional[StrictStr] = Field( + default=None, + description="The ID of the conversation.", + ) + status: Optional[MessageDeliveryStatusType] = Field( + default=None, + description="Shows the status of the message or event delivery.", + ) + channel_identity: Optional[ChannelIdentity] = Field( + default=None, + description="Channel identity of the recipient.", + ) + contact_id: Optional[StrictStr] = Field( + default=None, + description="The ID of the contact.", + ) + metadata: Optional[StrictStr] = Field( + default=None, + description="Metadata associated with the message.", + ) + processing_mode: Optional[ProcessingModeType] = Field( + default=None, + description="Processing mode (CONVERSATION or DISPATCH).", + ) + reason: Optional[Reason] = Field( + default=None, + description="Reason when status is FAILED.", + ) diff --git a/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_status_type.py b/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_status_type.py new file mode 100644 index 00000000..e5bc454c --- /dev/null +++ b/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_status_type.py @@ -0,0 +1,15 @@ +from typing import Literal, Union + +from pydantic import StrictStr + + +MessageDeliveryStatusType = Union[ + Literal[ + "QUEUED_ON_CHANNEL", + "DELIVERED", + "READ", + "FAILED", + "SWITCHING_CHANNEL", + ], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/webhooks/events/message_inbound_event.py b/sinch/domains/conversation/models/v1/webhooks/events/message_inbound_event.py new file mode 100644 index 00000000..89732cb7 --- /dev/null +++ b/sinch/domains/conversation/models/v1/webhooks/events/message_inbound_event.py @@ -0,0 +1,16 @@ +from pydantic import Field + +from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event_base import ( + ConversationWebhookEventBase, +) +from sinch.domains.conversation.models.v1.webhooks.events.inbound_message import ( + InboundMessage, +) + + +class MessageInboundEvent(ConversationWebhookEventBase): + """Webhook event for MESSAGE_INBOUND (inbound message from user).""" + + message: InboundMessage = Field( + description="The inbound message payload.", + ) diff --git a/sinch/domains/conversation/models/v1/webhooks/events/message_submit_event.py b/sinch/domains/conversation/models/v1/webhooks/events/message_submit_event.py new file mode 100644 index 00000000..6e539c9b --- /dev/null +++ b/sinch/domains/conversation/models/v1/webhooks/events/message_submit_event.py @@ -0,0 +1,16 @@ +from pydantic import Field + +from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event_base import ( + ConversationWebhookEventBase, +) +from sinch.domains.conversation.models.v1.webhooks.events.message_submit_notification import ( + MessageSubmitNotification, +) + + +class MessageSubmitEvent(ConversationWebhookEventBase): + """Webhook event for MESSAGE_SUBMIT (message submission notification).""" + + message_submit_notification: MessageSubmitNotification = Field( + description="The message submit notification payload.", + ) diff --git a/sinch/domains/conversation/models/v1/webhooks/events/message_submit_notification.py b/sinch/domains/conversation/models/v1/webhooks/events/message_submit_notification.py new file mode 100644 index 00000000..69ac499b --- /dev/null +++ b/sinch/domains/conversation/models/v1/webhooks/events/message_submit_notification.py @@ -0,0 +1,47 @@ +from typing import Optional + +from pydantic import Field, StrictStr + +from sinch.domains.conversation.webhooks.v1.internal import WebhookEvent +from sinch.domains.conversation.models.v1.messages.shared import ( + ChannelIdentity, +) +from sinch.domains.conversation.models.v1.messages.response.types.app_message import ( + AppMessage, +) +from sinch.domains.conversation.models.v1.messages.types.processing_mode_type import ( + ProcessingModeType, +) + + +class MessageSubmitNotification(WebhookEvent): + """Notification that an app message was submitted (MESSAGE_SUBMIT trigger).""" + + message_id: Optional[StrictStr] = Field( + default=None, + description="The ID of the app message.", + ) + conversation_id: Optional[StrictStr] = Field( + default=None, + description="The ID of the conversation. Empty if processing_mode is DISPATCH.", + ) + channel_identity: Optional[ChannelIdentity] = Field( + default=None, + description="Channel identity of the recipient.", + ) + contact_id: Optional[StrictStr] = Field( + default=None, + description="The ID of the contact. Empty if processing_mode is DISPATCH.", + ) + submitted_message: Optional[AppMessage] = Field( + default=None, + description="The submitted app message content (AppMessage).", + ) + metadata: Optional[StrictStr] = Field( + default=None, + description="Metadata from message_metadata of the Send Message request.", + ) + processing_mode: Optional[ProcessingModeType] = Field( + default=None, + description="Processing mode (CONVERSATION or DISPATCH).", + ) diff --git a/sinch/domains/conversation/webhooks/v1/__init__.py b/sinch/domains/conversation/webhooks/v1/__init__.py new file mode 100644 index 00000000..37b2f39f --- /dev/null +++ b/sinch/domains/conversation/webhooks/v1/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.conversation.webhooks.v1.conversation_webhooks import ( + ConversationWebhooks, +) + +__all__ = ["ConversationWebhooks"] diff --git a/sinch/domains/conversation/webhooks/v1/conversation_webhooks.py b/sinch/domains/conversation/webhooks/v1/conversation_webhooks.py new file mode 100644 index 00000000..f887d139 --- /dev/null +++ b/sinch/domains/conversation/webhooks/v1/conversation_webhooks.py @@ -0,0 +1,141 @@ +import logging +import re +from typing import Any, Dict, Union, Optional +from sinch.domains.authentication.webhooks.v1.authentication_validation import ( + validate_webhook_signature_with_nonce, +) +from sinch.domains.authentication.webhooks.v1.webhook_utils import ( + parse_json, + normalize_iso_timestamp, +) +from sinch.domains.conversation.models.v1.webhooks import ( + ConversationWebhookEventBase, + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, +) + + +logger = logging.getLogger(__name__) + + +def _charset_from_content_type(headers: Dict[str, str]) -> str: + """Extract charset from Content-Type header; default to utf-8.""" + ct = ( + (headers or {}).get("content-type") + or (headers or {}).get("Content-Type") + or "" + ) + match = re.search(r"charset\s*=\s*([^\s;]+)", ct, re.I) + return match.group(1).strip("'\"").lower() if match else "utf-8" + + +def _decode_payload( + payload: Union[str, bytes], headers: Optional[Dict[str, str]] = None +) -> str: + """Decode payload to str using Content-Type charset when payload is bytes.""" + if isinstance(payload, str): + return payload + charset = _charset_from_content_type(headers or {}) if headers else "utf-8" + try: + return payload.decode(charset) + except (LookupError, UnicodeDecodeError): + return payload.decode("utf-8") + + +ConversationWebhookCallback = Union[ + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, + ConversationWebhookEventBase, +] + + +class ConversationWebhooks: + """ + Handler for Conversation API webhooks: validate signature and parse events. + """ + + def __init__(self, webhook_secret: Optional[str] = None): + """ + :param webhook_secret: Secret configured for the webhook (used for HMAC validation). + """ + self.webhook_secret = webhook_secret + + def _validate_signature( + self, + payload: Union[str, bytes], + headers: Dict[str, str], + webhook_secret: Optional[str] = None, + ) -> bool: + """ + Validate the webhook signature using the request body and headers. + + Uses x-sinch-webhook-signature, x-sinch-webhook-signature-nonce, and + x-sinch-webhook-signature-timestamp. Returns True only if the signature + is valid. + + :param payload: Raw request body (string or bytes). + :param headers: Incoming request headers (key case is normalized to lower). + :param webhook_secret: Secret for this webhook; defaults to the secret passed to __init__. + :returns: True if the signature is valid, False otherwise. + """ + secret = ( + webhook_secret + if webhook_secret is not None + else self.webhook_secret + ) + if not secret: + return False + payload_str = _decode_payload(payload, headers) + return validate_webhook_signature_with_nonce( + secret, headers, payload_str + ) + + def validate_authentication_header( + self, headers: Dict[str, str], json_payload: str + ) -> bool: + """ + Validate the webhook signature (convenience wrapper around internal validation). + + :param headers: Incoming request's headers. + :param json_payload: Incoming request's raw body. + :returns: True if the X-Sinch-Webhook-Signature header is valid. + """ + return self._validate_signature(json_payload, headers) + + def parse_event( + self, event_body: Union[str, Dict[str, Any]] + ) -> ConversationWebhookCallback: + """ + Parse the webhook payload into a typed event. + + Parses by key: message_delivery_report → MessageDeliveryReceiptEvent, + message → MessageInboundEvent, message_submit_notification → MessageSubmitEvent. + Normalizes accepted_time and event_time. Injects trigger on the returned event. + + :param event_body: JSON string or dict of the webhook body. + :returns: Parsed event model. + :raises ValueError: If JSON parsing fails or the payload is invalid. + """ + if isinstance(event_body, str): + event_body = parse_json(event_body) + + # Normalize timestamp fields + for key in ("accepted_time", "event_time"): + if key in event_body and isinstance(event_body[key], str): + event_body[key] = normalize_iso_timestamp(event_body[key]) + + # Type is determined by which key is present (message_delivery_report, message, + # message_submit_notification). + if "message_delivery_report" in event_body: + return MessageDeliveryReceiptEvent(**event_body) + if "message" in event_body: + return MessageInboundEvent(**event_body) + if "message_submit_notification" in event_body: + return MessageSubmitEvent(**event_body) + + logger.warning( + "Conversation webhook: unknown event type; returning base event." + ) + return ConversationWebhookEventBase(**event_body) diff --git a/sinch/domains/conversation/webhooks/v1/internal/__init__.py b/sinch/domains/conversation/webhooks/v1/internal/__init__.py new file mode 100644 index 00000000..37ec3ff5 --- /dev/null +++ b/sinch/domains/conversation/webhooks/v1/internal/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.conversation.webhooks.v1.internal.webhook_event import ( + WebhookEvent, +) + +__all__ = ["WebhookEvent"] diff --git a/sinch/domains/conversation/webhooks/v1/internal/webhook_event.py b/sinch/domains/conversation/webhooks/v1/internal/webhook_event.py new file mode 100644 index 00000000..3c2e597e --- /dev/null +++ b/sinch/domains/conversation/webhooks/v1/internal/webhook_event.py @@ -0,0 +1,9 @@ +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class WebhookEvent(BaseModelConfiguration): + """Base model for Conversation API webhook events.""" + + pass diff --git a/tests/e2e/conversation/features/steps/conversation.steps.py b/tests/e2e/conversation/features/steps/conversation.steps.py index db251a59..630c476c 100644 --- a/tests/e2e/conversation/features/steps/conversation.steps.py +++ b/tests/e2e/conversation/features/steps/conversation.steps.py @@ -142,3 +142,33 @@ def step_validate_page_count(context, count): assert context.pages_iteration == expected_pages_count, ( f'Expected {expected_pages_count} pages, got {context.pages_iteration}' ) + + +@when('I send a request to list the last messages sent to specified channel identities') +def step_list_last_messages_channel_identities(context): + pass + + +@then('the response contains "{count}" last messages sent to specified channel identities') +def step_validate_last_messages_count(context, count): + pass + + +@when('I send a request to list all the last messages sent to specified channel identities') +def step_list_all_last_messages_channel_identities(context): + pass + + +@then('the response list contains "{count}" last messages sent to specified channel identities') +def step_validate_response_list_count(context, count): + pass + + +@when('I iterate manually over the last messages sent to specified channel identities pages') +def step_iterate_last_messages_pages(context): + pass + + +@then('the result contains the data from "{count}" pages of last messages sent to specified channel identities') +def step_validate_last_messages_page_count(context, count): + pass diff --git a/tests/e2e/conversation/features/steps/webhooks-events.steps.py b/tests/e2e/conversation/features/steps/webhooks-events.steps.py new file mode 100644 index 00000000..7c066f39 --- /dev/null +++ b/tests/e2e/conversation/features/steps/webhooks-events.steps.py @@ -0,0 +1,382 @@ +import requests +from behave import given, when, then +from sinch.domains.conversation.webhooks.v1 import ConversationWebhooks +from sinch.domains.conversation.models.v1.webhooks import ( + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, +) +from tests.e2e.helpers import has_key_or_attr, store_webhook_response + + +APP_SECRET = "CactusKnight_SurfsWaves" + + +def process_event(context, response): + store_webhook_response(context, response) + context.event = context.conversation_webhooks.parse_event(context.raw_event) + + +def _fetch_and_process(context, path_suffix): + base_url = context.sinch.configuration.conversation_origin + url = f"{base_url}/webhooks/conversation/{path_suffix}" + response = requests.get(url) + process_event(context, response) + + +@given("the Conversation Webhooks handler is available") +def step_conversation_webhooks_available(context): + context.sinch.configuration.auth_origin = "http://localhost:3014" + context.sinch.configuration.conversation_origin = "http://localhost:3014" + context.conversation_webhooks = ConversationWebhooks(APP_SECRET) + + +# --- CAPABILITY --- +@when('I send a request to trigger a "CAPABILITY" event') +def step_trigger_capability(context): + pass + + +# TODO: Refactor to parameterized step to avoid duplication. +@then('the header of the Conversation event "CAPABILITY" contains a valid signature') +def step_signature_valid_capability(context): + pass + + +@then('the Conversation event describes a "CAPABILITY" event type') +def step_describes_capability_event_type(context): + pass + + +# --- CONTACT_CREATE --- +@when('I send a request to trigger a "CONTACT_CREATE" event') +def step_trigger_contact_create(context): + pass + + +@then('the header of the Conversation event "CONTACT_CREATE" contains a valid signature') +def step_signature_valid_contact_create(context): + pass + + +@then('the Conversation event describes a "CONTACT_CREATE" event type') +def step_describes_contact_create_event_type(context): + pass + + +# --- CONTACT_DELETE --- +@when('I send a request to trigger a "CONTACT_DELETE" event') +def step_trigger_contact_delete(context): + pass + + +@then('the header of the Conversation event "CONTACT_DELETE" contains a valid signature') +def step_signature_valid_contact_delete(context): + pass + + +@then('the Conversation event describes a "CONTACT_DELETE" event type') +def step_describes_contact_delete_event_type(context): + pass + + +# --- CONTACT_MERGE --- +@when('I send a request to trigger a "CONTACT_MERGE" event') +def step_trigger_contact_merge(context): + pass + + +@then('the header of the Conversation event "CONTACT_MERGE" contains a valid signature') +def step_signature_valid_contact_merge(context): + pass + + +@then('the Conversation event describes a "CONTACT_MERGE" event type') +def step_describes_contact_merge_event_type(context): + pass + + +# --- CONTACT_UPDATE --- +@when('I send a request to trigger a "CONTACT_UPDATE" event') +def step_trigger_contact_update(context): + pass + + +@then('the header of the Conversation event "CONTACT_UPDATE" contains a valid signature') +def step_signature_valid_contact_update(context): + pass + + +@then('the Conversation event describes a "CONTACT_UPDATE" event type') +def step_describes_contact_update_event_type(context): + pass + + +# --- CONVERSATION_DELETE --- +@when('I send a request to trigger a "CONVERSATION_DELETE" event') +def step_trigger_conversation_delete(context): + pass + + +@then('the header of the Conversation event "CONVERSATION_DELETE" contains a valid signature') +def step_signature_valid_conversation_delete(context): + pass + + +@then('the Conversation event describes a "CONVERSATION_DELETE" event type') +def step_describes_conversation_delete_event_type(context): + pass + + +# --- CONVERSATION_START --- +@when('I send a request to trigger a "CONVERSATION_START" event') +def step_trigger_conversation_start(context): + pass + + +@then('the header of the Conversation event "CONVERSATION_START" contains a valid signature') +def step_signature_valid_conversation_start(context): + pass + + +@then('the Conversation event describes a "CONVERSATION_START" event type') +def step_describes_conversation_start_event_type(context): + pass + + +# --- CONVERSATION_STOP --- +@when('I send a request to trigger a "CONVERSATION_STOP" event') +def step_trigger_conversation_stop(context): + pass + + +@then('the header of the Conversation event "CONVERSATION_STOP" contains a valid signature') +def step_signature_valid_conversation_stop(context): + pass + + +@then('the Conversation event describes a "CONVERSATION_STOP" event type') +def step_describes_conversation_stop_event_type(context): + pass + + +# --- EVENT_DELIVERY (FAILED) --- +@when('I send a request to trigger a "EVENT_DELIVERY" event with a "FAILED" status') +def step_trigger_event_delivery_failed(context): + _fetch_and_process(context, "event-delivery-report/failed") + + +@then('the header of the Conversation event "EVENT_DELIVERY" with a "FAILED" status contains a valid signature') +def step_signature_valid_event_delivery_failed(context): + assert context.conversation_webhooks.validate_authentication_header( + context.webhook_headers, context.raw_event + ), "Signature validation failed for event EVENT_DELIVERY with status FAILED" + + +@then('the Conversation event describes a "EVENT_DELIVERY" event type') +def step_describes_event_delivery_event_type(context): + pass + + +@then("the Conversation event describes a FAILED event delivery status and its reason") +def step_check_failed_event_delivery_reason(context): + pass + + +# --- EVENT_DELIVERY (DELIVERED) --- +@when('I send a request to trigger a "EVENT_DELIVERY" event with a "DELIVERED" status') +def step_trigger_event_delivery_delivered(context): + _fetch_and_process(context, "event-delivery-report/succeeded") + + +@then('the header of the Conversation event "EVENT_DELIVERY" with a "DELIVERED" status contains a valid signature') +def step_signature_valid_event_delivery_delivered(context): + assert context.conversation_webhooks.validate_authentication_header( + context.webhook_headers, context.raw_event + ), "Signature validation failed for event EVENT_DELIVERY with status DELIVERED" + + +# --- EVENT_INBOUND --- +@when('I send a request to trigger a "EVENT_INBOUND" event') +def step_trigger_event_inbound(context): + pass + + +@then('the header of the Conversation event "EVENT_INBOUND" contains a valid signature') +def step_signature_valid_event_inbound(context): + pass + + +@then('the Conversation event describes a "EVENT_INBOUND" event type') +def step_describes_event_inbound_event_type(context): + pass + + +# --- MESSAGE_DELIVERY (FAILED) --- +@when('I send a request to trigger a "MESSAGE_DELIVERY" event with a "FAILED" status') +def step_trigger_message_delivery_failed(context): + _fetch_and_process(context, "message-delivery-report/failed") + + +@then('the header of the Conversation event "MESSAGE_DELIVERY" with a "FAILED" status contains a valid signature') +def step_signature_valid_message_delivery_failed(context): + assert context.conversation_webhooks.validate_authentication_header( + context.webhook_headers, context.raw_event + ), "Signature validation failed for event MESSAGE_DELIVERY with status FAILED" + + +@then('the Conversation event describes a "MESSAGE_DELIVERY" event type') +def step_describes_message_delivery_event_type(context): + event = context.event + assert isinstance(event, MessageDeliveryReceiptEvent), ( + f"Expected MessageDeliveryReceiptEvent, got {type(event)}" + ) + assert event.message_delivery_report is not None, "message_delivery_report must be present" + + +@then("the Conversation event describes a FAILED message delivery status and its reason") +def step_check_failed_message_delivery_reason(context): + message_delivery_report = context.event.message_delivery_report + assert message_delivery_report is not None, "message_delivery_report is missing" + assert message_delivery_report.status == "FAILED", ( + f"Expected status 'FAILED', got {message_delivery_report.status!r}" + ) + assert message_delivery_report.reason is not None, "reason is missing for FAILED delivery" + assert message_delivery_report.reason.code == "RECIPIENT_NOT_REACHABLE", ( + f"Expected reason code 'RECIPIENT_NOT_REACHABLE', got {message_delivery_report.reason.code!r}" + ) + + +# --- MESSAGE_DELIVERY (QUEUED_ON_CHANNEL) --- +@when('I send a request to trigger a "MESSAGE_DELIVERY" event with a "QUEUED_ON_CHANNEL" status') +def step_trigger_message_delivery_queued(context): + _fetch_and_process(context, "message-delivery-report/succeeded") + + +@then('the header of the Conversation event "MESSAGE_DELIVERY" with a "QUEUED_ON_CHANNEL" status contains a valid signature') +def step_signature_valid_message_delivery_queued(context): + assert context.conversation_webhooks.validate_authentication_header( + context.webhook_headers, context.raw_event + ), "Signature validation failed for event MESSAGE_DELIVERY with status QUEUED_ON_CHANNEL" + + +# --- MESSAGE_INBOUND --- +@when('I send a request to trigger a "MESSAGE_INBOUND" event') +def step_trigger_message_inbound(context): + _fetch_and_process(context, "message-inbound") + + +@then('the header of the Conversation event "MESSAGE_INBOUND" contains a valid signature') +def step_signature_valid_message_inbound(context): + assert context.conversation_webhooks.validate_authentication_header( + context.webhook_headers, context.raw_event + ), "Signature validation failed for event MESSAGE_INBOUND" + + +@then('the Conversation event describes a "MESSAGE_INBOUND" event type') +def step_describes_message_inbound_event_type(context): + event = context.event + assert isinstance(event, MessageInboundEvent), ( + f"Expected MessageInboundEvent, got {type(event)}" + ) + assert event.message is not None, "message must be present" + + +# --- MESSAGE_INBOUND_SMART_CONVERSATION_REDACTION --- +@when('I send a request to trigger a "MESSAGE_INBOUND_SMART_CONVERSATION_REDACTION" event') +def step_trigger_message_inbound_smart_conversation_redaction(context): + pass + + +@then('the header of the Conversation event "MESSAGE_INBOUND_SMART_CONVERSATION_REDACTION" contains a valid signature') +def step_signature_valid_message_inbound_smart_conversation_redaction(context): + pass + + +@then('the Conversation event describes a "MESSAGE_INBOUND_SMART_CONVERSATION_REDACTION" event type') +def step_describes_message_inbound_smart_conversation_redaction_event_type(context): + pass + + +# --- MESSAGE_SUBMIT (media) --- +@when('I send a request to trigger a "MESSAGE_SUBMIT" event for a "media" message') +def step_trigger_message_submit_media(context): + _fetch_and_process(context, "message-submit/media") + + +@then('the header of the Conversation event "MESSAGE_SUBMIT" for a "media" message contains a valid signature') +def step_signature_valid_message_submit_media(context): + assert context.conversation_webhooks.validate_authentication_header( + context.webhook_headers, context.raw_event + ), "Signature validation failed for event MESSAGE_SUBMIT for media message" + + +@then('the Conversation event describes a "MESSAGE_SUBMIT" event type for a "media" message') +def step_check_message_submit_media(context): + message_submit_event = context.event + assert isinstance(message_submit_event, MessageSubmitEvent), ( + f"Expected MessageSubmitEvent, got {type(message_submit_event)}" + ) + assert message_submit_event.message_submit_notification is not None + submitted = message_submit_event.message_submit_notification.submitted_message + assert has_key_or_attr(submitted, "media_message"), ( + "Expected submitted_message.media_message to be present" + ) + + +# --- MESSAGE_SUBMIT (text) --- +@when('I send a request to trigger a "MESSAGE_SUBMIT" event for a "text" message') +def step_trigger_message_submit_text(context): + _fetch_and_process(context, "message-submit/text") + + +@then('the header of the Conversation event "MESSAGE_SUBMIT" for a "text" message contains a valid signature') +def step_signature_valid_message_submit_text(context): + assert context.conversation_webhooks.validate_authentication_header( + context.webhook_headers, context.raw_event + ), "Signature validation failed for event MESSAGE_SUBMIT for text message" + + +@then('the Conversation event describes a "MESSAGE_SUBMIT" event type for a "text" message') +def step_check_message_submit_text(context): + message_submit_event = context.event + assert isinstance(message_submit_event, MessageSubmitEvent), ( + f"Expected MessageSubmitEvent, got {type(message_submit_event)}" + ) + assert message_submit_event.message_submit_notification is not None + submitted = message_submit_event.message_submit_notification.submitted_message + assert has_key_or_attr(submitted, "text_message"), ( + "Expected submitted_message.text_message to be present" + ) + + +# --- SMART_CONVERSATIONS (media) --- +@when('I send a request to trigger a "SMART_CONVERSATIONS" event for a "media" message') +def step_trigger_smart_conversations_media(context): + pass + + +@then('the header of the Conversation event "SMART_CONVERSATIONS" for a "media" message contains a valid signature') +def step_signature_valid_smart_conversations_media(context): + pass + + +@then('the Conversation event describes a "SMART_CONVERSATIONS" event type for a "media" message') +def step_check_smart_conversations_media(context): + pass + + +# --- SMART_CONVERSATIONS (text) --- +@when('I send a request to trigger a "SMART_CONVERSATIONS" event for a "text" message') +def step_trigger_smart_conversations_text(context): + pass + + +@then('the header of the Conversation event "SMART_CONVERSATIONS" for a "text" message contains a valid signature') +def step_signature_valid_smart_conversations_text(context): + pass + + +@then('the Conversation event describes a "SMART_CONVERSATIONS" event type for a "text" message') +def step_check_smart_conversations_text(context): + pass diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py new file mode 100644 index 00000000..0f20e188 --- /dev/null +++ b/tests/e2e/helpers.py @@ -0,0 +1,16 @@ +""" +Common utility helpers for E2E tests, shared across domains. +""" + + +def store_webhook_response(context, response): + context.webhook_headers = dict(response.headers) + context.raw_event = response.text + + +def has_key_or_attr(obj, key): + if obj is None: + return False + if isinstance(obj, dict): + return key in obj and obj[key] is not None + return getattr(obj, key, None) is not None diff --git a/tests/e2e/numbers/features/steps/webhooks.steps.py b/tests/e2e/numbers/features/steps/webhooks.steps.py index a681fd96..28409ff2 100644 --- a/tests/e2e/numbers/features/steps/webhooks.steps.py +++ b/tests/e2e/numbers/features/steps/webhooks.steps.py @@ -1,16 +1,11 @@ import json import requests from behave import given, when, then +from tests.e2e.helpers import store_webhook_response SINCH_NUMBERS_CALLBACK_SECRET = 'strongPa$$PhraseWith36CharactersMax' -def parse_event(context, response): - context.headers = response.headers - context.raw_event = response.text - return json.loads(context.raw_event) - - @given('the Numbers Webhooks handler is available') def step_webhook_handler_is_available(context): context.numbers_webhook = context.sinch.numbers.webhooks(SINCH_NUMBERS_CALLBACK_SECRET) @@ -20,14 +15,15 @@ def step_webhook_handler_is_available(context): def step_send_trigger_event(context, status, event_type): endpoint = 'succeeded' if status == 'success' else 'failed' response = requests.get(f'http://localhost:3013/webhooks/numbers/provisioning_to_voice_platform/{endpoint}') - event_json = parse_event(context, response) + store_webhook_response(context, response) + event_json = json.loads(context.raw_event) context.event = context.numbers_webhook.parse_event(event_json) @then('the header of the "{status}" for "{event_type}" event contains a valid signature') def step_check_valid_signature(context, status, event_type): assert context.numbers_webhook.validate_authentication_header( - context.headers, context.raw_event + context.webhook_headers, context.raw_event ), 'Signature validation failed' diff --git a/tests/e2e/sms/features/steps/webhooks.steps.py b/tests/e2e/sms/features/steps/webhooks.steps.py index 1ce5a626..096ae9cd 100644 --- a/tests/e2e/sms/features/steps/webhooks.steps.py +++ b/tests/e2e/sms/features/steps/webhooks.steps.py @@ -9,15 +9,11 @@ BatchDeliveryReport, RecipientDeliveryReport, ) +from tests.e2e.helpers import store_webhook_response SINCH_SMS_CALLBACK_SECRET = 'KayakingTheSwell' -def parse_event(context, response): - context.headers = dict(response.headers) - context.raw_event = response.text - - @given('the SMS Webhooks handler is available') def step_webhook_handler_is_available(context): context.sms_webhook = SmsWebhooks(SINCH_SMS_CALLBACK_SECRET) @@ -26,7 +22,7 @@ def step_webhook_handler_is_available(context): @when('I send a request to trigger an "incoming SMS" event') def step_send_incoming_sms_event(context): response = requests.get('http://localhost:3017/webhooks/sms/incoming-sms') - parse_event(context, response) + store_webhook_response(context, response) context.event = context.sms_webhook.parse_event(context.raw_event) @@ -34,7 +30,7 @@ def step_send_incoming_sms_event(context): @then('the header of the event "{event_type}" with the status "{status}" contains a valid signature') def step_check_valid_signature(context, event_type, status=None): assert context.sms_webhook.validate_authentication_header( - context.headers, context.raw_event + context.webhook_headers, context.raw_event ), 'Signature validation failed' @@ -54,7 +50,7 @@ def step_check_incoming_sms_event(context): @when('I send a request to trigger an "SMS delivery report" event') def step_send_delivery_report_event(context): response = requests.get('http://localhost:3017/webhooks/sms/delivery-report-sms') - parse_event(context, response) + store_webhook_response(context, response) context.event = context.sms_webhook.parse_event(context.raw_event) @@ -82,7 +78,7 @@ def step_send_recipient_delivery_report_event_delivered(context): response = requests.get( 'http://localhost:3017/webhooks/sms/recipient-delivery-report-sms-delivered' ) - parse_event(context, response) + store_webhook_response(context, response) context.event = context.sms_webhook.parse_event(context.raw_event) @@ -91,7 +87,7 @@ def step_send_recipient_delivery_report_event_aborted(context): response = requests.get( 'http://localhost:3017/webhooks/sms/recipient-delivery-report-sms-aborted' ) - parse_event(context, response) + store_webhook_response(context, response) context.event = context.sms_webhook.parse_event(context.raw_event) diff --git a/tests/unit/domains/conversation/v1/models/webhooks/events/test_conversation_webhooks_event_model.py b/tests/unit/domains/conversation/v1/models/webhooks/events/test_conversation_webhooks_event_model.py new file mode 100644 index 00000000..d062e0a0 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/webhooks/events/test_conversation_webhooks_event_model.py @@ -0,0 +1,82 @@ +"""Unit tests for Conversation webhook event models.""" +import pytest + +from sinch.domains.conversation.models.v1.webhooks import ( + ConversationWebhookEventBase, + MessageDeliveryReceiptEvent, + MessageDeliveryReport, + MessageInboundEvent, + MessageSubmitEvent, +) + + +@pytest.fixture +def message_delivery_report_data(): + return { + "message_id": "01EQBC1A3BEK731GY4YXEN0C2R", + "conversation_id": "01EPYATA64TMNZ1FV02JKF12JF", + "status": "QUEUED_ON_CHANNEL", + "contact_id": "01EXA07N79THJ20WSN6AS30TMW", + "channel_identity": {"channel": "WHATSAPP", "identity": "1234567890"}, + } + + +def test_message_delivery_report_expects_parsed(message_delivery_report_data): + report = MessageDeliveryReport(**message_delivery_report_data) + assert report.message_id == "01EQBC1A3BEK731GY4YXEN0C2R" + assert report.conversation_id == "01EPYATA64TMNZ1FV02JKF12JF" + assert report.status == "QUEUED_ON_CHANNEL" + assert report.contact_id == "01EXA07N79THJ20WSN6AS30TMW" + assert report.channel_identity is not None + assert report.channel_identity.channel == "WHATSAPP" + assert report.channel_identity.identity == "1234567890" + + +def test_message_delivery_receipt_event_expects_parsed(message_delivery_report_data): + payload = { + "app_id": "app1", + "project_id": "proj1", + "accepted_time": "2020-11-17T15:09:11.659Z", + "message_delivery_report": message_delivery_report_data, + } + event = MessageDeliveryReceiptEvent(**payload) + assert event.app_id == "app1" + assert event.message_delivery_report is not None + assert event.message_delivery_report.message_id == "01EQBC1A3BEK731GY4YXEN0C2R" + + +def test_message_inbound_event_expects_parsed(): + payload = { + "app_id": "app1", + "message": { + "contact_id": "contact1", + "contact_message": {"text_message": {"text": "Hello"}}, + "channel_identity": {"channel": "SMS", "identity": "+15551234567"}, + }, + } + event = MessageInboundEvent(**payload) + assert event.message is not None + assert event.message.contact_id == "contact1" + assert event.message.contact_message.text_message.text == "Hello" + + +def test_message_submit_event_expects_parsed(): + payload = { + "app_id": "app1", + "message_submit_notification": { + "contact_id": "contact1", + "channel_identity": {"channel": "MESSENGER", "identity": "123"}, + }, + } + event = MessageSubmitEvent(**payload) + assert event.message_submit_notification is not None + assert event.message_submit_notification.contact_id == "contact1" + + +def test_conversation_webhook_event_base_optional_fields(): + payload = {"app_id": "app1"} + event = ConversationWebhookEventBase(**payload) + assert event.app_id == "app1" + assert event.project_id is None + assert event.accepted_time is None + assert event.event_time is None diff --git a/tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py b/tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py new file mode 100644 index 00000000..f5e50212 --- /dev/null +++ b/tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py @@ -0,0 +1,129 @@ +"""Unit tests for Conversation API webhooks (signature validation and parse_event).""" +from datetime import datetime, timezone + +import pytest + +from sinch.domains.conversation.webhooks.v1 import ConversationWebhooks +from sinch.domains.conversation.models.v1.webhooks import ( + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, +) + + +@pytest.fixture +def webhook_secret(): + return "foo_secret1234" + + +@pytest.fixture +def conversation_webhooks(webhook_secret): + return ConversationWebhooks(webhook_secret) + + +@pytest.fixture +def sample_body(): + return ( + '{"app_id":"01EB37HMH1M6SV18BSNS3G135H","accepted_time":"2020-11-17T15:09:11.659Z",' + '"project_id":"c36f3d3d-1513-2edd-ae42-11995557ff61",' + '"message_delivery_report":{"message_id":"01EQBC1A3BEK731GY4YXEN0C2R",' + '"conversation_id":"01EPYATA64TMNZ1FV02JKF12JF","status":"QUEUED_ON_CHANNEL",' + '"contact_id":"01EXA07N79THJ20WSN6AS30TMW"}}' + ) + + +VALID_SIGNATURE_HEADERS = { + "x-sinch-webhook-signature": "Yc+3R1pIS78xLASybulhs8BsSo9BPB3Pr92QCUoczfk=", + "x-sinch-webhook-signature-nonce": "01FJA8B4A7BM43YGWSG9GBV067", + "x-sinch-webhook-signature-timestamp": "1634579353", +} + + +def test_validate_authentication_header_valid_expects_true(conversation_webhooks, sample_body): + assert conversation_webhooks.validate_authentication_header( + VALID_SIGNATURE_HEADERS, sample_body + ) is True + + +def test_validate_authentication_header_missing_headers_expects_false(conversation_webhooks, sample_body): + assert conversation_webhooks.validate_authentication_header({}, sample_body) is False + + +def test_validate_authentication_header_invalid_signature_expects_false(conversation_webhooks, sample_body): + headers = { + "x-sinch-webhook-signature": "invalid", + "x-sinch-webhook-signature-nonce": "01FJA8B4A7BM43YGWSG9GBV067", + "x-sinch-webhook-signature-timestamp": "1634579353", + } + assert conversation_webhooks.validate_authentication_header(headers, sample_body) is False + + +def test_parse_event_message_delivery_expects_message_delivery_receipt_event(conversation_webhooks): + payload = { + "app_id": "01EB37HMH1M6SV18BSNS3G135H", + "project_id": "c36f3d3d-1513-2edd-ae42-11995557ff61", + "accepted_time": "2020-11-17T15:09:11.659Z", + "event_time": "2020-11-17T15:09:13.267185Z", + "message_delivery_report": { + "message_id": "01EQBC1A3BEK731GY4YXEN0C2R", + "conversation_id": "01EPYATA64TMNZ1FV02JKF12JF", + "status": "QUEUED_ON_CHANNEL", + "contact_id": "01EXA07N79THJ20WSN6AS30TMW", + }, + } + event = conversation_webhooks.parse_event(payload) + assert isinstance(event, MessageDeliveryReceiptEvent) + assert event.message_delivery_report is not None + assert event.message_delivery_report.message_id == "01EQBC1A3BEK731GY4YXEN0C2R" + assert event.message_delivery_report.status == "QUEUED_ON_CHANNEL" + assert event.accepted_time == datetime(2020, 11, 17, 15, 9, 11, 659000, tzinfo=timezone.utc) + + +def test_parse_event_message_inbound_expects_message_inbound_event(conversation_webhooks): + payload = { + "app_id": "01EB37HMH1M6SV18BSNS3G135H", + "project_id": "c36f3d3d-1513-2edd-ae42-11995557ff61", + "accepted_time": "2020-11-17T15:09:11.659Z", + "message": { + "contact_id": "01EXA07N79THJ20WSN6AS30TMW", + "contact_message": {"text_message": {"text": "Hello"}}, + "channel_identity": {"channel": "WHATSAPP", "identity": "1234567890"}, + }, + } + event = conversation_webhooks.parse_event(payload) + assert isinstance(event, MessageInboundEvent) + assert event.message is not None + assert event.message.contact_id == "01EXA07N79THJ20WSN6AS30TMW" + assert event.message.contact_message is not None + assert hasattr(event.message.contact_message, "text_message") + assert event.message.contact_message.text_message.text == "Hello" + + +def test_parse_event_message_submit_expects_message_submit_event(conversation_webhooks): + payload = { + "app_id": "01EB37HMH1M6SV18BSNS3G135H", + "project_id": "c36f3d3d-1513-2edd-ae42-11995557ff61", + "accepted_time": "2020-11-17T15:09:11.659Z", + "message_submit_notification": { + "contact_id": "01EXA07N79THJ20WSN6AS30TMW", + "channel_identity": {"channel": "WHATSAPP", "identity": "1234567890"}, + "submitted_message": {"text_message": {"text": "Hi"}}, + }, + } + event = conversation_webhooks.parse_event(payload) + assert isinstance(event, MessageSubmitEvent) + assert event.message_submit_notification is not None + assert event.message_submit_notification.contact_id == "01EXA07N79THJ20WSN6AS30TMW" + + +def test_parse_event_json_string_expects_parsed(conversation_webhooks): + payload_str = '{"app_id":"app1","message_delivery_report":{"status":"DELIVERED"}}' + event = conversation_webhooks.parse_event(payload_str) + assert isinstance(event, MessageDeliveryReceiptEvent) + assert event.app_id == "app1" + assert event.message_delivery_report.status == "DELIVERED" + + +def test_parse_event_invalid_json_expects_value_error(conversation_webhooks): + with pytest.raises(ValueError, match="Failed to decode JSON"): + conversation_webhooks.parse_event("not json")