From 11ae9755ce1311a66ffe7aad9dc04d4a513569b3 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 19 Feb 2026 09:48:04 +0100 Subject: [PATCH 1/3] DEVEXP-795: Conversation Webhooks --- .github/workflows/ci.yml | 1 + examples/webhooks/.env.example | 4 +- examples/webhooks/README.md | 18 +- .../webhooks/conversation_api/__init__.py | 0 .../webhooks/conversation_api/controller.py | 30 ++ .../conversation_api/server_business_logic.py | 91 +++++ examples/webhooks/server.py | 4 + sinch/domains/conversation/conversation.py | 12 + .../conversation/webhooks/v1/__init__.py | 5 + .../webhooks/v1/conversation_webhooks.py | 119 +++++++ .../webhooks/v1/events/__init__.py | 21 ++ .../v1/events/conversation_webhooks_event.py | 181 ++++++++++ .../webhooks/v1/internal/__init__.py | 5 + .../webhooks/v1/internal/webhook_event.py | 9 + .../features/steps/conversation.steps.py | 30 ++ .../features/steps/webhooks-events.steps.py | 316 ++++++++++++++++++ .../test_conversation_webhooks_event_model.py | 102 ++++++ .../v1/webhooks/test_conversation_webhooks.py | 171 ++++++++++ 18 files changed, 1113 insertions(+), 6 deletions(-) create mode 100644 examples/webhooks/conversation_api/__init__.py create mode 100644 examples/webhooks/conversation_api/controller.py create mode 100644 examples/webhooks/conversation_api/server_business_logic.py create mode 100644 sinch/domains/conversation/webhooks/v1/__init__.py create mode 100644 sinch/domains/conversation/webhooks/v1/conversation_webhooks.py create mode 100644 sinch/domains/conversation/webhooks/v1/events/__init__.py create mode 100644 sinch/domains/conversation/webhooks/v1/events/conversation_webhooks_event.py create mode 100644 sinch/domains/conversation/webhooks/v1/internal/__init__.py create mode 100644 sinch/domains/conversation/webhooks/v1/internal/webhook_event.py create mode 100644 tests/e2e/conversation/features/steps/webhooks-events.steps.py create mode 100644 tests/unit/domains/conversation/v1/webhooks/events/test_conversation_webhooks_event_model.py create mode 100644 tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py 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..cba04f35 --- /dev/null +++ b/examples/webhooks/conversation_api/controller.py @@ -0,0 +1,30 @@ +from flask import request, Response +from webhooks.conversation_api.server_business_logic import handle_conversation_event + + +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) + body_str = request.raw_body.decode("utf-8") if request.raw_body else "" + + 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..dee74e90 --- /dev/null +++ b/examples/webhooks/conversation_api/server_business_logic.py @@ -0,0 +1,91 @@ +from sinch.domains.conversation.webhooks.v1.events 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.info("Conversation webhook: unknown or unhandled trigger %s", getattr(event, "trigger", None)) + 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") + if not event.message: + logger.warning("MESSAGE_INBOUND event has no message") + return + 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 + if not report: + logger.warning("MESSAGE_DELIVERY event has no message_delivery_report") + return + 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") + notif = event.message_submit_notification + if not notif: + logger.warning("MESSAGE_SUBMIT event has no message_submit_notification") + return + channel_identity = notif.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, + notif.contact_id, + ) + if notif.submitted_message: + logger.debug("Submitted message: %s", notif.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/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..61820c4b --- /dev/null +++ b/sinch/domains/conversation/webhooks/v1/conversation_webhooks.py @@ -0,0 +1,119 @@ +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.webhooks.v1.events import ( + ConversationWebhookEventBase, + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, +) + + +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 + if isinstance(payload, bytes): + payload = payload.decode("utf-8") + return validate_webhook_signature_with_nonce(secret, headers, payload) + + def validate_authentication_header( + self, headers: Dict[str, str], json_payload: str + ) -> bool: + """ + Validate the webhook signature (convenience alias for validate_signature). + + :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). Inject trigger so callers can use event.trigger. + trigger = event_body.get("trigger") + if not trigger and "message_delivery_report" in event_body: + trigger = "MESSAGE_DELIVERY" + if not trigger and "message" in event_body: + trigger = "MESSAGE_INBOUND" + if not trigger and "message_submit_notification" in event_body: + trigger = "MESSAGE_SUBMIT" + + if trigger == "MESSAGE_DELIVERY": + event_body = {**event_body, "trigger": "MESSAGE_DELIVERY"} + return MessageDeliveryReceiptEvent(**event_body) + if trigger == "MESSAGE_INBOUND": + event_body = {**event_body, "trigger": "MESSAGE_INBOUND"} + return MessageInboundEvent(**event_body) + if trigger == "MESSAGE_SUBMIT": + event_body = {**event_body, "trigger": "MESSAGE_SUBMIT"} + return MessageSubmitEvent(**event_body) + + return ConversationWebhookEventBase(**event_body) diff --git a/sinch/domains/conversation/webhooks/v1/events/__init__.py b/sinch/domains/conversation/webhooks/v1/events/__init__.py new file mode 100644 index 00000000..67636c23 --- /dev/null +++ b/sinch/domains/conversation/webhooks/v1/events/__init__.py @@ -0,0 +1,21 @@ +from sinch.domains.conversation.webhooks.v1.events.conversation_webhooks_event import ( + ConversationWebhookEventBase, + ConversationWebhookEvent, + InboundMessage, + MessageDeliveryReceiptEvent, + MessageDeliveryReport, + MessageInboundEvent, + MessageSubmitEvent, + MessageSubmitNotification, +) + +__all__ = [ + "ConversationWebhookEvent", + "ConversationWebhookEventBase", + "InboundMessage", + "MessageDeliveryReceiptEvent", + "MessageDeliveryReport", + "MessageInboundEvent", + "MessageSubmitEvent", + "MessageSubmitNotification", +] diff --git a/sinch/domains/conversation/webhooks/v1/events/conversation_webhooks_event.py b/sinch/domains/conversation/webhooks/v1/events/conversation_webhooks_event.py new file mode 100644 index 00000000..e17a44fa --- /dev/null +++ b/sinch/domains/conversation/webhooks/v1/events/conversation_webhooks_event.py @@ -0,0 +1,181 @@ +from datetime import datetime +from typing import Any, Literal, Optional, Union +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.shared.message_common_props import ( + MessageCommonProps, +) +from sinch.domains.conversation.models.v1.messages.response.types.contact_message import ( + ContactMessage, +) +from sinch.domains.conversation.models.v1.messages.types.processing_mode_type import ( + ProcessingModeType, +) + + +class ConversationWebhookEventBase(WebhookEvent): + """Base fields present on every Conversation API webhook payload.""" + + trigger: Optional[StrictStr] = Field( + default=None, + description="The webhook trigger type (e.g. MESSAGE_INBOUND, MESSAGE_DELIVERY, MESSAGE_SUBMIT).", + ) + 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.", + ) + channel_metadata: Optional[Any] = Field( + default=None, + description="Additional metadata from the channel.", + ) + + +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[StrictStr] = Field( + default=None, + description="Delivery status (QUEUED_ON_CHANNEL, DELIVERED, READ, FAILED, SWITCHING_CHANNEL).", + ) + 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.", + ) + + +class MessageDeliveryReceiptEvent(ConversationWebhookEventBase): + """Webhook event for MESSAGE_DELIVERY (delivery receipt for app messages).""" + + trigger: Literal["MESSAGE_DELIVERY"] = Field( + ..., + description="Webhook trigger type.", + ) + message_delivery_report: Optional[MessageDeliveryReport] = Field( + default=None, + description="The delivery report payload.", + ) + + +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.", + ) + + +class MessageInboundEvent(ConversationWebhookEventBase): + """Webhook event for MESSAGE_INBOUND (inbound message from user).""" + + trigger: Literal["MESSAGE_INBOUND"] = Field( + ..., + description="Webhook trigger type.", + ) + message: Optional[InboundMessage] = Field( + default=None, + description="The inbound message payload.", + ) + + +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[Any] = 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).", + ) + + +class MessageSubmitEvent(ConversationWebhookEventBase): + """Webhook event for MESSAGE_SUBMIT (message submission notification).""" + + trigger: Literal["MESSAGE_SUBMIT"] = Field( + ..., + description="Webhook trigger type.", + ) + message_submit_notification: Optional[MessageSubmitNotification] = Field( + default=None, + description="The message submit notification payload.", + ) + + +ConversationWebhookEvent = Union[ + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, + ConversationWebhookEventBase, +] 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..1de8d76d --- /dev/null +++ b/tests/e2e/conversation/features/steps/webhooks-events.steps.py @@ -0,0 +1,316 @@ +import requests +from behave import given, when, then +from sinch.domains.conversation.webhooks.v1 import ConversationWebhooks +from sinch.domains.conversation.webhooks.v1.events import ( + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, +) + + +APP_SECRET = "CactusKnight_SurfsWaves" + + +def process_event(context, response): + context.formatted_headers = dict(response.headers) + context.raw_event = response.text + 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) + + +def _has_fetched_event(context): + return getattr(context, "raw_event", None) is not None + + +def _submitted_has(submitted, key): + if submitted is None: + return False + if isinstance(submitted, dict): + return key in submitted and submitted[key] is not None + return getattr(submitted, key, None) is not None + + +@given("the Conversation Webhooks handler is available") +def step_conversation_webhooks_available(context): + context.conversation_webhooks = ConversationWebhooks(APP_SECRET) + + +# --- CAPABILITY --- +@when('I send a request to trigger a "CAPABILITY" event') +def step_trigger_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 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 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 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 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 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 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 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): + pass + + +@then('the header of the Conversation event {event_type} with a {status} status contains a valid signature') +def step_signature_valid_with_status(context, event_type, status): + if not _has_fetched_event(context): + return + assert context.conversation_webhooks.validate_authentication_header( + context.formatted_headers, context.raw_event + ), f"Signature validation failed for event {event_type} with status {status}" + + +@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): + pass + + +# --- EVENT_INBOUND --- +@when('I send a request to trigger a "EVENT_INBOUND" event') +def step_trigger_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 / QUEUED_ON_CHANNEL) --- +@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") + + +@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 Conversation event describes a "MESSAGE_DELIVERY" event type') +def step_describes_message_delivery_event_type(context): + if not _has_fetched_event(context): + return + 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" + assert event.trigger == "MESSAGE_DELIVERY", f"Expected trigger 'MESSAGE_DELIVERY', got {event.trigger!r}" + + +@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_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 Conversation event describes a "MESSAGE_INBOUND" event type') +def step_describes_message_inbound_event_type(context): + if not _has_fetched_event(context): + return + event = context.event + assert isinstance(event, MessageInboundEvent), ( + f"Expected MessageInboundEvent, got {type(event)}" + ) + assert event.message is not None, "message must be present" + assert event.trigger == "MESSAGE_INBOUND", f"Expected trigger 'MESSAGE_INBOUND', got {event.trigger!r}" + + +# --- 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 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 {event_type} for a {message_type} message contains a valid signature') +def step_signature_valid_message_type(context, event_type, message_type): + if not _has_fetched_event(context): + return + assert context.conversation_webhooks.validate_authentication_header( + context.formatted_headers, context.raw_event + ), f"Signature validation failed for event {event_type} for {message_type} 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 + assert message_submit_event.trigger == "MESSAGE_SUBMIT" + submitted = message_submit_event.message_submit_notification.submitted_message + assert _submitted_has(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 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 + assert message_submit_event.trigger == "MESSAGE_SUBMIT" + submitted = message_submit_event.message_submit_notification.submitted_message + assert _submitted_has(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 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 Conversation event describes a "SMART_CONVERSATIONS" event type for a "text" message') +def step_check_smart_conversations_text(context): + pass + + +@then('the header of the Conversation event {event_type} contains a valid signature') +def step_signature_valid(context, event_type): + if not _has_fetched_event(context): + return + assert context.conversation_webhooks.validate_authentication_header( + context.formatted_headers, context.raw_event + ), f"Signature validation failed for event {event_type}" diff --git a/tests/unit/domains/conversation/v1/webhooks/events/test_conversation_webhooks_event_model.py b/tests/unit/domains/conversation/v1/webhooks/events/test_conversation_webhooks_event_model.py new file mode 100644 index 00000000..70211197 --- /dev/null +++ b/tests/unit/domains/conversation/v1/webhooks/events/test_conversation_webhooks_event_model.py @@ -0,0 +1,102 @@ +"""Unit tests for Conversation webhook event models.""" +from datetime import datetime, timezone + +import pytest +from pydantic import ValidationError + +from sinch.domains.conversation.webhooks.v1.events import ( + ConversationWebhookEventBase, + MessageDeliveryReceiptEvent, + MessageDeliveryReport, + MessageInboundEvent, + MessageSubmitEvent, + MessageSubmitNotification, +) + + +@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 = { + "trigger": "MESSAGE_DELIVERY", + "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.trigger == "MESSAGE_DELIVERY" + 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 = { + "trigger": "MESSAGE_INBOUND", + "app_id": "app1", + "message": { + "contact_id": "contact1", + "contact_message": {"text_message": {"text": "Hello"}}, + "channel_identity": {"channel": "SMS", "identity": "+15551234567"}, + }, + } + event = MessageInboundEvent(**payload) + assert event.trigger == "MESSAGE_INBOUND" + 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 = { + "trigger": "MESSAGE_SUBMIT", + "app_id": "app1", + "message_submit_notification": { + "contact_id": "contact1", + "channel_identity": {"channel": "MESSENGER", "identity": "123"}, + }, + } + event = MessageSubmitEvent(**payload) + assert event.trigger == "MESSAGE_SUBMIT" + 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 = {"trigger": "UNKNOWN", "app_id": "app1"} + event = ConversationWebhookEventBase(**payload) + assert event.trigger == "UNKNOWN" + assert event.app_id == "app1" + assert event.project_id is None + assert event.accepted_time is None + assert event.event_time is None + + +def test_message_delivery_receipt_event_wrong_trigger_expects_validation_error(message_delivery_report_data): + payload = { + "trigger": "MESSAGE_INBOUND", + "message_delivery_report": message_delivery_report_data, + } + with pytest.raises(ValidationError): + MessageDeliveryReceiptEvent(**payload) 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..a0fc4c54 --- /dev/null +++ b/tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py @@ -0,0 +1,171 @@ +"""Unit tests for Conversation API webhooks (signature validation and parse_event).""" +from datetime import datetime, timezone + +import pytest + +from sinch.domains.authentication.webhooks.v1.authentication_validation import ( + compute_signed_data, + calculate_webhook_signature, +) +from sinch.domains.conversation.webhooks.v1 import ConversationWebhooks +from sinch.domains.conversation.webhooks.v1.events import ( + ConversationWebhookEventBase, + 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","trigger":"MESSAGE_DELIVERY",' + '"message_delivery_report":{"message_id":"01EQBC1A3BEK731GY4YXEN0C2R",' + '"conversation_id":"01EPYATA64TMNZ1FV02JKF12JF","status":"QUEUED_ON_CHANNEL",' + '"contact_id":"01EXA07N79THJ20WSN6AS30TMW"}}' + ) + + +def _make_signed_headers(body: str, secret: str, nonce: str = "01FJA8B4A7BM43YGWSG9GBV067", timestamp: str = "1634579353"): + signed_data = compute_signed_data(body, nonce, timestamp) + sig = calculate_webhook_signature(signed_data, secret) + return { + "x-sinch-webhook-signature": sig, + "x-sinch-webhook-signature-nonce": nonce, + "x-sinch-webhook-signature-timestamp": timestamp, + } + + +def test_validate_signature_valid_expects_true(conversation_webhooks, sample_body, webhook_secret): + headers = _make_signed_headers(sample_body, webhook_secret) + assert conversation_webhooks.validate_signature(sample_body, headers) is True + + +def test_validate_signature_missing_headers_expects_false(conversation_webhooks, sample_body): + assert conversation_webhooks.validate_signature(sample_body, {}) is False + + +def test_validate_signature_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_signature(sample_body, headers) is False + + +def test_parse_event_message_delivery_expects_message_delivery_receipt_event(conversation_webhooks): + payload = { + "trigger": "MESSAGE_DELIVERY", + "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.trigger == "MESSAGE_DELIVERY" + 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 = { + "trigger": "MESSAGE_INBOUND", + "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.trigger == "MESSAGE_INBOUND" + 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 = { + "trigger": "MESSAGE_SUBMIT", + "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.trigger == "MESSAGE_SUBMIT" + assert event.message_submit_notification is not None + assert event.message_submit_notification.contact_id == "01EXA07N79THJ20WSN6AS30TMW" + + +def test_parse_event_unknown_trigger_expects_base_event(conversation_webhooks): + payload = { + "trigger": "CONTACT_CREATE", + "app_id": "01EB37HMH1M6SV18BSNS3G135H", + "project_id": "c36f3d3d-1513-2edd-ae42-11995557ff61", + } + event = conversation_webhooks.parse_event(payload) + assert isinstance(event, ConversationWebhookEventBase) + assert event.trigger == "CONTACT_CREATE" + assert not isinstance(event, (MessageDeliveryReceiptEvent, MessageInboundEvent, MessageSubmitEvent)) + + +def test_parse_event_json_string_expects_parsed(conversation_webhooks): + payload_str = '{"trigger":"MESSAGE_DELIVERY","app_id":"app1","message_delivery_report":{"status":"SUCCESS"}}' + event = conversation_webhooks.parse_event(payload_str) + assert isinstance(event, MessageDeliveryReceiptEvent) + assert event.app_id == "app1" + assert event.message_delivery_report.status == "SUCCESS" + + +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") + + +def test_parse_event_without_trigger_uses_discriminant(conversation_webhooks): + """Payloads without 'trigger' are parsed by which key is present (OpenAPI oneOf).""" + payload = { + "app_id": "app1", + "project_id": "proj1", + "accepted_time": "2020-11-17T15:09:11.659Z", + "message_delivery_report": { + "message_id": "msg1", + "status": "DELIVERED", + }, + } + event = conversation_webhooks.parse_event(payload) + assert isinstance(event, MessageDeliveryReceiptEvent) + assert event.trigger == "MESSAGE_DELIVERY" + assert event.message_delivery_report.message_id == "msg1" From b09c6afea99f83b600ef512741efd699edbe8ae0 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 19 Feb 2026 18:33:42 +0100 Subject: [PATCH 2/3] update logic --- .../conversation_api/server_business_logic.py | 19 +++------ .../webhooks/v1/conversation_webhooks.py | 19 ++------- .../v1/events/conversation_webhooks_event.py | 26 ++----------- .../features/steps/webhooks-events.steps.py | 4 -- .../test_conversation_webhooks_event_model.py | 19 +-------- .../v1/webhooks/test_conversation_webhooks.py | 39 +------------------ 6 files changed, 16 insertions(+), 110 deletions(-) diff --git a/examples/webhooks/conversation_api/server_business_logic.py b/examples/webhooks/conversation_api/server_business_logic.py index dee74e90..6b755e0e 100644 --- a/examples/webhooks/conversation_api/server_business_logic.py +++ b/examples/webhooks/conversation_api/server_business_logic.py @@ -27,9 +27,6 @@ def handle_conversation_event(event: ConversationWebhookEventBase, logger): def _handle_message_inbound(event: MessageInboundEvent, logger): """Handle MESSAGE_INBOUND: log inbound message.""" logger.info("## MESSAGE_INBOUND") - if not event.message: - logger.warning("MESSAGE_INBOUND event has no message") - return msg = event.message contact_msg = msg.contact_message channel_identity = msg.channel_identity @@ -57,9 +54,6 @@ 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 - if not report: - logger.warning("MESSAGE_DELIVERY event has no message_delivery_report") - return status = report.status logger.info("Message delivery status: '%s'", status) if status == "FAILED" and report.reason: @@ -74,18 +68,15 @@ def _handle_message_delivery(event: MessageDeliveryReceiptEvent, logger): def _handle_message_submit(event: MessageSubmitEvent, logger): """Handle MESSAGE_SUBMIT: log that the message was submitted to the channel.""" logger.info("## MESSAGE_SUBMIT") - notif = event.message_submit_notification - if not notif: - logger.warning("MESSAGE_SUBMIT event has no message_submit_notification") - return - channel_identity = notif.channel_identity + 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, - notif.contact_id, + submit_notification.contact_id, ) - if notif.submitted_message: - logger.debug("Submitted message: %s", notif.submitted_message) + if submit_notification.submitted_message: + logger.debug("Submitted message: %s", submit_notification.submitted_message) diff --git a/sinch/domains/conversation/webhooks/v1/conversation_webhooks.py b/sinch/domains/conversation/webhooks/v1/conversation_webhooks.py index 61820c4b..9715555c 100644 --- a/sinch/domains/conversation/webhooks/v1/conversation_webhooks.py +++ b/sinch/domains/conversation/webhooks/v1/conversation_webhooks.py @@ -97,23 +97,12 @@ def parse_event( event_body[key] = normalize_iso_timestamp(event_body[key]) # Type is determined by which key is present (message_delivery_report, message, - # message_submit_notification). Inject trigger so callers can use event.trigger. - trigger = event_body.get("trigger") - if not trigger and "message_delivery_report" in event_body: - trigger = "MESSAGE_DELIVERY" - if not trigger and "message" in event_body: - trigger = "MESSAGE_INBOUND" - if not trigger and "message_submit_notification" in event_body: - trigger = "MESSAGE_SUBMIT" - - if trigger == "MESSAGE_DELIVERY": - event_body = {**event_body, "trigger": "MESSAGE_DELIVERY"} + # message_submit_notification). + if "message_delivery_report" in event_body: return MessageDeliveryReceiptEvent(**event_body) - if trigger == "MESSAGE_INBOUND": - event_body = {**event_body, "trigger": "MESSAGE_INBOUND"} + if "message" in event_body: return MessageInboundEvent(**event_body) - if trigger == "MESSAGE_SUBMIT": - event_body = {**event_body, "trigger": "MESSAGE_SUBMIT"} + if "message_submit_notification" in event_body: return MessageSubmitEvent(**event_body) return ConversationWebhookEventBase(**event_body) diff --git a/sinch/domains/conversation/webhooks/v1/events/conversation_webhooks_event.py b/sinch/domains/conversation/webhooks/v1/events/conversation_webhooks_event.py index e17a44fa..fc61031a 100644 --- a/sinch/domains/conversation/webhooks/v1/events/conversation_webhooks_event.py +++ b/sinch/domains/conversation/webhooks/v1/events/conversation_webhooks_event.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Any, Literal, Optional, Union +from typing import Any, Optional, Union from pydantic import Field, StrictStr from sinch.domains.conversation.webhooks.v1.internal import WebhookEvent @@ -21,10 +21,6 @@ class ConversationWebhookEventBase(WebhookEvent): """Base fields present on every Conversation API webhook payload.""" - trigger: Optional[StrictStr] = Field( - default=None, - description="The webhook trigger type (e.g. MESSAGE_INBOUND, MESSAGE_DELIVERY, MESSAGE_SUBMIT).", - ) app_id: Optional[StrictStr] = Field( default=None, description="Id of the subscribed app.", @@ -95,11 +91,7 @@ class MessageDeliveryReport(WebhookEvent): class MessageDeliveryReceiptEvent(ConversationWebhookEventBase): """Webhook event for MESSAGE_DELIVERY (delivery receipt for app messages).""" - trigger: Literal["MESSAGE_DELIVERY"] = Field( - ..., - description="Webhook trigger type.", - ) - message_delivery_report: Optional[MessageDeliveryReport] = Field( + message_delivery_report: MessageDeliveryReport = Field( default=None, description="The delivery report payload.", ) @@ -117,12 +109,7 @@ class InboundMessage(MessageCommonProps, WebhookEvent): class MessageInboundEvent(ConversationWebhookEventBase): """Webhook event for MESSAGE_INBOUND (inbound message from user).""" - trigger: Literal["MESSAGE_INBOUND"] = Field( - ..., - description="Webhook trigger type.", - ) - message: Optional[InboundMessage] = Field( - default=None, + message: InboundMessage = Field( description="The inbound message payload.", ) @@ -163,12 +150,7 @@ class MessageSubmitNotification(WebhookEvent): class MessageSubmitEvent(ConversationWebhookEventBase): """Webhook event for MESSAGE_SUBMIT (message submission notification).""" - trigger: Literal["MESSAGE_SUBMIT"] = Field( - ..., - description="Webhook trigger type.", - ) - message_submit_notification: Optional[MessageSubmitNotification] = Field( - default=None, + message_submit_notification: MessageSubmitNotification = Field( description="The message submit notification payload.", ) diff --git a/tests/e2e/conversation/features/steps/webhooks-events.steps.py b/tests/e2e/conversation/features/steps/webhooks-events.steps.py index 1de8d76d..c7862025 100644 --- a/tests/e2e/conversation/features/steps/webhooks-events.steps.py +++ b/tests/e2e/conversation/features/steps/webhooks-events.steps.py @@ -191,7 +191,6 @@ def step_describes_message_delivery_event_type(context): f"Expected MessageDeliveryReceiptEvent, got {type(event)}" ) assert event.message_delivery_report is not None, "message_delivery_report must be present" - assert event.trigger == "MESSAGE_DELIVERY", f"Expected trigger 'MESSAGE_DELIVERY', got {event.trigger!r}" @then("the Conversation event describes a FAILED message delivery status and its reason") @@ -222,7 +221,6 @@ def step_describes_message_inbound_event_type(context): f"Expected MessageInboundEvent, got {type(event)}" ) assert event.message is not None, "message must be present" - assert event.trigger == "MESSAGE_INBOUND", f"Expected trigger 'MESSAGE_INBOUND', got {event.trigger!r}" # --- MESSAGE_INBOUND_SMART_CONVERSATION_REDACTION --- @@ -258,7 +256,6 @@ def step_check_message_submit_media(context): f"Expected MessageSubmitEvent, got {type(message_submit_event)}" ) assert message_submit_event.message_submit_notification is not None - assert message_submit_event.trigger == "MESSAGE_SUBMIT" submitted = message_submit_event.message_submit_notification.submitted_message assert _submitted_has(submitted, "media_message"), ( "Expected submitted_message.media_message to be present" @@ -278,7 +275,6 @@ def step_check_message_submit_text(context): f"Expected MessageSubmitEvent, got {type(message_submit_event)}" ) assert message_submit_event.message_submit_notification is not None - assert message_submit_event.trigger == "MESSAGE_SUBMIT" submitted = message_submit_event.message_submit_notification.submitted_message assert _submitted_has(submitted, "text_message"), ( "Expected submitted_message.text_message to be present" diff --git a/tests/unit/domains/conversation/v1/webhooks/events/test_conversation_webhooks_event_model.py b/tests/unit/domains/conversation/v1/webhooks/events/test_conversation_webhooks_event_model.py index 70211197..cdbdbcff 100644 --- a/tests/unit/domains/conversation/v1/webhooks/events/test_conversation_webhooks_event_model.py +++ b/tests/unit/domains/conversation/v1/webhooks/events/test_conversation_webhooks_event_model.py @@ -10,7 +10,6 @@ MessageDeliveryReport, MessageInboundEvent, MessageSubmitEvent, - MessageSubmitNotification, ) @@ -38,14 +37,12 @@ def test_message_delivery_report_expects_parsed(message_delivery_report_data): def test_message_delivery_receipt_event_expects_parsed(message_delivery_report_data): payload = { - "trigger": "MESSAGE_DELIVERY", "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.trigger == "MESSAGE_DELIVERY" assert event.app_id == "app1" assert event.message_delivery_report is not None assert event.message_delivery_report.message_id == "01EQBC1A3BEK731GY4YXEN0C2R" @@ -53,7 +50,6 @@ def test_message_delivery_receipt_event_expects_parsed(message_delivery_report_d def test_message_inbound_event_expects_parsed(): payload = { - "trigger": "MESSAGE_INBOUND", "app_id": "app1", "message": { "contact_id": "contact1", @@ -62,7 +58,6 @@ def test_message_inbound_event_expects_parsed(): }, } event = MessageInboundEvent(**payload) - assert event.trigger == "MESSAGE_INBOUND" assert event.message is not None assert event.message.contact_id == "contact1" assert event.message.contact_message.text_message.text == "Hello" @@ -70,7 +65,6 @@ def test_message_inbound_event_expects_parsed(): def test_message_submit_event_expects_parsed(): payload = { - "trigger": "MESSAGE_SUBMIT", "app_id": "app1", "message_submit_notification": { "contact_id": "contact1", @@ -78,25 +72,14 @@ def test_message_submit_event_expects_parsed(): }, } event = MessageSubmitEvent(**payload) - assert event.trigger == "MESSAGE_SUBMIT" 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 = {"trigger": "UNKNOWN", "app_id": "app1"} + payload = {"app_id": "app1"} event = ConversationWebhookEventBase(**payload) - assert event.trigger == "UNKNOWN" assert event.app_id == "app1" assert event.project_id is None assert event.accepted_time is None assert event.event_time is None - - -def test_message_delivery_receipt_event_wrong_trigger_expects_validation_error(message_delivery_report_data): - payload = { - "trigger": "MESSAGE_INBOUND", - "message_delivery_report": message_delivery_report_data, - } - with pytest.raises(ValidationError): - MessageDeliveryReceiptEvent(**payload) diff --git a/tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py b/tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py index a0fc4c54..269ed340 100644 --- a/tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py +++ b/tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py @@ -30,7 +30,7 @@ def conversation_webhooks(webhook_secret): def sample_body(): return ( '{"app_id":"01EB37HMH1M6SV18BSNS3G135H","accepted_time":"2020-11-17T15:09:11.659Z",' - '"project_id":"c36f3d3d-1513-2edd-ae42-11995557ff61","trigger":"MESSAGE_DELIVERY",' + '"project_id":"c36f3d3d-1513-2edd-ae42-11995557ff61",' '"message_delivery_report":{"message_id":"01EQBC1A3BEK731GY4YXEN0C2R",' '"conversation_id":"01EPYATA64TMNZ1FV02JKF12JF","status":"QUEUED_ON_CHANNEL",' '"contact_id":"01EXA07N79THJ20WSN6AS30TMW"}}' @@ -67,7 +67,6 @@ def test_validate_signature_invalid_signature_expects_false(conversation_webhook def test_parse_event_message_delivery_expects_message_delivery_receipt_event(conversation_webhooks): payload = { - "trigger": "MESSAGE_DELIVERY", "app_id": "01EB37HMH1M6SV18BSNS3G135H", "project_id": "c36f3d3d-1513-2edd-ae42-11995557ff61", "accepted_time": "2020-11-17T15:09:11.659Z", @@ -81,7 +80,6 @@ def test_parse_event_message_delivery_expects_message_delivery_receipt_event(con } event = conversation_webhooks.parse_event(payload) assert isinstance(event, MessageDeliveryReceiptEvent) - assert event.trigger == "MESSAGE_DELIVERY" 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" @@ -90,7 +88,6 @@ def test_parse_event_message_delivery_expects_message_delivery_receipt_event(con def test_parse_event_message_inbound_expects_message_inbound_event(conversation_webhooks): payload = { - "trigger": "MESSAGE_INBOUND", "app_id": "01EB37HMH1M6SV18BSNS3G135H", "project_id": "c36f3d3d-1513-2edd-ae42-11995557ff61", "accepted_time": "2020-11-17T15:09:11.659Z", @@ -102,7 +99,6 @@ def test_parse_event_message_inbound_expects_message_inbound_event(conversation_ } event = conversation_webhooks.parse_event(payload) assert isinstance(event, MessageInboundEvent) - assert event.trigger == "MESSAGE_INBOUND" assert event.message is not None assert event.message.contact_id == "01EXA07N79THJ20WSN6AS30TMW" assert event.message.contact_message is not None @@ -112,7 +108,6 @@ def test_parse_event_message_inbound_expects_message_inbound_event(conversation_ def test_parse_event_message_submit_expects_message_submit_event(conversation_webhooks): payload = { - "trigger": "MESSAGE_SUBMIT", "app_id": "01EB37HMH1M6SV18BSNS3G135H", "project_id": "c36f3d3d-1513-2edd-ae42-11995557ff61", "accepted_time": "2020-11-17T15:09:11.659Z", @@ -124,25 +119,12 @@ def test_parse_event_message_submit_expects_message_submit_event(conversation_we } event = conversation_webhooks.parse_event(payload) assert isinstance(event, MessageSubmitEvent) - assert event.trigger == "MESSAGE_SUBMIT" assert event.message_submit_notification is not None assert event.message_submit_notification.contact_id == "01EXA07N79THJ20WSN6AS30TMW" -def test_parse_event_unknown_trigger_expects_base_event(conversation_webhooks): - payload = { - "trigger": "CONTACT_CREATE", - "app_id": "01EB37HMH1M6SV18BSNS3G135H", - "project_id": "c36f3d3d-1513-2edd-ae42-11995557ff61", - } - event = conversation_webhooks.parse_event(payload) - assert isinstance(event, ConversationWebhookEventBase) - assert event.trigger == "CONTACT_CREATE" - assert not isinstance(event, (MessageDeliveryReceiptEvent, MessageInboundEvent, MessageSubmitEvent)) - - def test_parse_event_json_string_expects_parsed(conversation_webhooks): - payload_str = '{"trigger":"MESSAGE_DELIVERY","app_id":"app1","message_delivery_report":{"status":"SUCCESS"}}' + payload_str = '{"app_id":"app1","message_delivery_report":{"status":"SUCCESS"}}' event = conversation_webhooks.parse_event(payload_str) assert isinstance(event, MessageDeliveryReceiptEvent) assert event.app_id == "app1" @@ -152,20 +134,3 @@ def test_parse_event_json_string_expects_parsed(conversation_webhooks): 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") - - -def test_parse_event_without_trigger_uses_discriminant(conversation_webhooks): - """Payloads without 'trigger' are parsed by which key is present (OpenAPI oneOf).""" - payload = { - "app_id": "app1", - "project_id": "proj1", - "accepted_time": "2020-11-17T15:09:11.659Z", - "message_delivery_report": { - "message_id": "msg1", - "status": "DELIVERED", - }, - } - event = conversation_webhooks.parse_event(payload) - assert isinstance(event, MessageDeliveryReceiptEvent) - assert event.trigger == "MESSAGE_DELIVERY" - assert event.message_delivery_report.message_id == "msg1" From 9fd25c52a12dbdb05dea334028211dea16e2abcb Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Fri, 20 Feb 2026 17:39:36 +0100 Subject: [PATCH 3/3] fix comments --- .../webhooks/conversation_api/controller.py | 24 ++- .../conversation_api/server_business_logic.py | 3 +- .../events => models/v1/webhooks}/__init__.py | 6 +- .../models/v1/webhooks/events/__init__.py | 39 ++++ .../events/conversation_webhook_event.py | 22 +++ .../events/conversation_webhook_event_base.py | 35 ++++ .../v1/webhooks/events/inbound_message.py | 20 +++ .../events/message_delivery_receipt_event.py | 17 ++ .../events/message_delivery_report.py | 53 ++++++ .../events/message_delivery_status_type.py | 15 ++ .../webhooks/events/message_inbound_event.py | 16 ++ .../webhooks/events/message_submit_event.py | 16 ++ .../events/message_submit_notification.py | 47 +++++ .../webhooks/v1/conversation_webhooks.py | 47 ++++- .../v1/events/conversation_webhooks_event.py | 163 ----------------- .../features/steps/webhooks-events.steps.py | 166 +++++++++++++----- tests/e2e/helpers.py | 16 ++ .../numbers/features/steps/webhooks.steps.py | 12 +- .../e2e/sms/features/steps/webhooks.steps.py | 16 +- .../test_conversation_webhooks_event_model.py | 5 +- .../v1/webhooks/test_conversation_webhooks.py | 39 ++-- 21 files changed, 509 insertions(+), 268 deletions(-) rename sinch/domains/conversation/{webhooks/v1/events => models/v1/webhooks}/__init__.py (78%) create mode 100644 sinch/domains/conversation/models/v1/webhooks/events/__init__.py create mode 100644 sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event.py create mode 100644 sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event_base.py create mode 100644 sinch/domains/conversation/models/v1/webhooks/events/inbound_message.py create mode 100644 sinch/domains/conversation/models/v1/webhooks/events/message_delivery_receipt_event.py create mode 100644 sinch/domains/conversation/models/v1/webhooks/events/message_delivery_report.py create mode 100644 sinch/domains/conversation/models/v1/webhooks/events/message_delivery_status_type.py create mode 100644 sinch/domains/conversation/models/v1/webhooks/events/message_inbound_event.py create mode 100644 sinch/domains/conversation/models/v1/webhooks/events/message_submit_event.py create mode 100644 sinch/domains/conversation/models/v1/webhooks/events/message_submit_notification.py delete mode 100644 sinch/domains/conversation/webhooks/v1/events/conversation_webhooks_event.py create mode 100644 tests/e2e/helpers.py rename tests/unit/domains/conversation/v1/{ => models}/webhooks/events/test_conversation_webhooks_event_model.py (95%) diff --git a/examples/webhooks/conversation_api/controller.py b/examples/webhooks/conversation_api/controller.py index cba04f35..0e5abb4c 100644 --- a/examples/webhooks/conversation_api/controller.py +++ b/examples/webhooks/conversation_api/controller.py @@ -1,7 +1,27 @@ +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 @@ -10,7 +30,9 @@ def __init__(self, sinch_client, webhooks_secret): def conversation_event(self): headers = dict(request.headers) - body_str = request.raw_body.decode("utf-8") if request.raw_body else "" + 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) diff --git a/examples/webhooks/conversation_api/server_business_logic.py b/examples/webhooks/conversation_api/server_business_logic.py index 6b755e0e..03ef74e9 100644 --- a/examples/webhooks/conversation_api/server_business_logic.py +++ b/examples/webhooks/conversation_api/server_business_logic.py @@ -1,4 +1,4 @@ -from sinch.domains.conversation.webhooks.v1.events import ( +from sinch.domains.conversation.models.v1.webhooks import ( ConversationWebhookEventBase, MessageDeliveryReceiptEvent, MessageInboundEvent, @@ -20,7 +20,6 @@ def handle_conversation_event(event: ConversationWebhookEventBase, logger): elif isinstance(event, MessageSubmitEvent): _handle_message_submit(event, logger) else: - logger.info("Conversation webhook: unknown or unhandled trigger %s", getattr(event, "trigger", None)) logger.debug("Event: %s", event.model_dump_json(indent=2) if hasattr(event, "model_dump_json") else event) diff --git a/sinch/domains/conversation/webhooks/v1/events/__init__.py b/sinch/domains/conversation/models/v1/webhooks/__init__.py similarity index 78% rename from sinch/domains/conversation/webhooks/v1/events/__init__.py rename to sinch/domains/conversation/models/v1/webhooks/__init__.py index 67636c23..5a8208fa 100644 --- a/sinch/domains/conversation/webhooks/v1/events/__init__.py +++ b/sinch/domains/conversation/models/v1/webhooks/__init__.py @@ -1,9 +1,10 @@ -from sinch.domains.conversation.webhooks.v1.events.conversation_webhooks_event import ( - ConversationWebhookEventBase, +from sinch.domains.conversation.models.v1.webhooks.events import ( ConversationWebhookEvent, + ConversationWebhookEventBase, InboundMessage, MessageDeliveryReceiptEvent, MessageDeliveryReport, + MessageDeliveryStatusType, MessageInboundEvent, MessageSubmitEvent, MessageSubmitNotification, @@ -15,6 +16,7 @@ "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/conversation_webhooks.py b/sinch/domains/conversation/webhooks/v1/conversation_webhooks.py index 9715555c..f887d139 100644 --- a/sinch/domains/conversation/webhooks/v1/conversation_webhooks.py +++ b/sinch/domains/conversation/webhooks/v1/conversation_webhooks.py @@ -1,3 +1,5 @@ +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, @@ -6,7 +8,7 @@ parse_json, normalize_iso_timestamp, ) -from sinch.domains.conversation.webhooks.v1.events import ( +from sinch.domains.conversation.models.v1.webhooks import ( ConversationWebhookEventBase, MessageDeliveryReceiptEvent, MessageInboundEvent, @@ -14,6 +16,33 @@ ) +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, @@ -33,7 +62,7 @@ def __init__(self, webhook_secret: Optional[str] = None): """ self.webhook_secret = webhook_secret - def validate_signature( + def _validate_signature( self, payload: Union[str, bytes], headers: Dict[str, str], @@ -58,21 +87,22 @@ def validate_signature( ) if not secret: return False - if isinstance(payload, bytes): - payload = payload.decode("utf-8") - return validate_webhook_signature_with_nonce(secret, headers, payload) + 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 alias for validate_signature). + 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) + return self._validate_signature(json_payload, headers) def parse_event( self, event_body: Union[str, Dict[str, Any]] @@ -105,4 +135,7 @@ def parse_event( 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/events/conversation_webhooks_event.py b/sinch/domains/conversation/webhooks/v1/events/conversation_webhooks_event.py deleted file mode 100644 index fc61031a..00000000 --- a/sinch/domains/conversation/webhooks/v1/events/conversation_webhooks_event.py +++ /dev/null @@ -1,163 +0,0 @@ -from datetime import datetime -from typing import Any, Optional, Union -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.shared.message_common_props import ( - MessageCommonProps, -) -from sinch.domains.conversation.models.v1.messages.response.types.contact_message import ( - ContactMessage, -) -from sinch.domains.conversation.models.v1.messages.types.processing_mode_type import ( - ProcessingModeType, -) - - -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.", - ) - channel_metadata: Optional[Any] = Field( - default=None, - description="Additional metadata from the channel.", - ) - - -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[StrictStr] = Field( - default=None, - description="Delivery status (QUEUED_ON_CHANNEL, DELIVERED, READ, FAILED, SWITCHING_CHANNEL).", - ) - 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.", - ) - - -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.", - ) - - -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.", - ) - - -class MessageInboundEvent(ConversationWebhookEventBase): - """Webhook event for MESSAGE_INBOUND (inbound message from user).""" - - message: InboundMessage = Field( - description="The inbound message payload.", - ) - - -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[Any] = 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).", - ) - - -class MessageSubmitEvent(ConversationWebhookEventBase): - """Webhook event for MESSAGE_SUBMIT (message submission notification).""" - - message_submit_notification: MessageSubmitNotification = Field( - description="The message submit notification payload.", - ) - - -ConversationWebhookEvent = Union[ - MessageDeliveryReceiptEvent, - MessageInboundEvent, - MessageSubmitEvent, - ConversationWebhookEventBase, -] diff --git a/tests/e2e/conversation/features/steps/webhooks-events.steps.py b/tests/e2e/conversation/features/steps/webhooks-events.steps.py index c7862025..7c066f39 100644 --- a/tests/e2e/conversation/features/steps/webhooks-events.steps.py +++ b/tests/e2e/conversation/features/steps/webhooks-events.steps.py @@ -1,19 +1,19 @@ import requests from behave import given, when, then from sinch.domains.conversation.webhooks.v1 import ConversationWebhooks -from sinch.domains.conversation.webhooks.v1.events import ( +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): - context.formatted_headers = dict(response.headers) - context.raw_event = response.text + store_webhook_response(context, response) context.event = context.conversation_webhooks.parse_event(context.raw_event) @@ -24,20 +24,10 @@ def _fetch_and_process(context, path_suffix): process_event(context, response) -def _has_fetched_event(context): - return getattr(context, "raw_event", None) is not None - - -def _submitted_has(submitted, key): - if submitted is None: - return False - if isinstance(submitted, dict): - return key in submitted and submitted[key] is not None - return getattr(submitted, key, None) is not None - - @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) @@ -47,6 +37,12 @@ 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 @@ -58,6 +54,11 @@ 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 @@ -69,6 +70,11 @@ 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 @@ -80,6 +86,11 @@ 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 @@ -91,6 +102,11 @@ 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 @@ -102,6 +118,11 @@ 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 @@ -113,6 +134,11 @@ 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 @@ -124,6 +150,11 @@ 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 @@ -132,16 +163,14 @@ def step_describes_conversation_stop_event_type(context): # --- 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): - pass + _fetch_and_process(context, "event-delivery-report/failed") -@then('the header of the Conversation event {event_type} with a {status} status contains a valid signature') -def step_signature_valid_with_status(context, event_type, status): - if not _has_fetched_event(context): - return +@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.formatted_headers, context.raw_event - ), f"Signature validation failed for event {event_type} with status {status}" + 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') @@ -157,7 +186,14 @@ def step_check_failed_event_delivery_reason(context): # --- 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): - pass + _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 --- @@ -166,26 +202,31 @@ 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 / QUEUED_ON_CHANNEL) --- +# --- 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") -@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 "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): - if not _has_fetched_event(context): - return event = context.event assert isinstance(event, MessageDeliveryReceiptEvent), ( f"Expected MessageDeliveryReceiptEvent, got {type(event)}" @@ -206,16 +247,34 @@ def step_check_failed_message_delivery_reason(context): ) +# --- 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): - if not _has_fetched_event(context): - return event = context.event assert isinstance(event, MessageInboundEvent), ( f"Expected MessageInboundEvent, got {type(event)}" @@ -229,6 +288,11 @@ 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 @@ -240,13 +304,11 @@ def step_trigger_message_submit_media(context): _fetch_and_process(context, "message-submit/media") -@then('the header of the Conversation event {event_type} for a {message_type} message contains a valid signature') -def step_signature_valid_message_type(context, event_type, message_type): - if not _has_fetched_event(context): - return +@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.formatted_headers, context.raw_event - ), f"Signature validation failed for event {event_type} for {message_type} message" + 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') @@ -257,7 +319,7 @@ def step_check_message_submit_media(context): ) assert message_submit_event.message_submit_notification is not None submitted = message_submit_event.message_submit_notification.submitted_message - assert _submitted_has(submitted, "media_message"), ( + assert has_key_or_attr(submitted, "media_message"), ( "Expected submitted_message.media_message to be present" ) @@ -268,6 +330,13 @@ 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 @@ -276,7 +345,7 @@ def step_check_message_submit_text(context): ) assert message_submit_event.message_submit_notification is not None submitted = message_submit_event.message_submit_notification.submitted_message - assert _submitted_has(submitted, "text_message"), ( + assert has_key_or_attr(submitted, "text_message"), ( "Expected submitted_message.text_message to be present" ) @@ -287,6 +356,11 @@ 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 @@ -298,15 +372,11 @@ def step_trigger_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): +@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 header of the Conversation event {event_type} contains a valid signature') -def step_signature_valid(context, event_type): - if not _has_fetched_event(context): - return - assert context.conversation_webhooks.validate_authentication_header( - context.formatted_headers, context.raw_event - ), f"Signature validation failed for event {event_type}" +@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/webhooks/events/test_conversation_webhooks_event_model.py b/tests/unit/domains/conversation/v1/models/webhooks/events/test_conversation_webhooks_event_model.py similarity index 95% rename from tests/unit/domains/conversation/v1/webhooks/events/test_conversation_webhooks_event_model.py rename to tests/unit/domains/conversation/v1/models/webhooks/events/test_conversation_webhooks_event_model.py index cdbdbcff..d062e0a0 100644 --- a/tests/unit/domains/conversation/v1/webhooks/events/test_conversation_webhooks_event_model.py +++ b/tests/unit/domains/conversation/v1/models/webhooks/events/test_conversation_webhooks_event_model.py @@ -1,10 +1,7 @@ """Unit tests for Conversation webhook event models.""" -from datetime import datetime, timezone - import pytest -from pydantic import ValidationError -from sinch.domains.conversation.webhooks.v1.events import ( +from sinch.domains.conversation.models.v1.webhooks import ( ConversationWebhookEventBase, MessageDeliveryReceiptEvent, MessageDeliveryReport, diff --git a/tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py b/tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py index 269ed340..f5e50212 100644 --- a/tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py +++ b/tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py @@ -3,13 +3,8 @@ import pytest -from sinch.domains.authentication.webhooks.v1.authentication_validation import ( - compute_signed_data, - calculate_webhook_signature, -) from sinch.domains.conversation.webhooks.v1 import ConversationWebhooks -from sinch.domains.conversation.webhooks.v1.events import ( - ConversationWebhookEventBase, +from sinch.domains.conversation.models.v1.webhooks import ( MessageDeliveryReceiptEvent, MessageInboundEvent, MessageSubmitEvent, @@ -37,32 +32,30 @@ def sample_body(): ) -def _make_signed_headers(body: str, secret: str, nonce: str = "01FJA8B4A7BM43YGWSG9GBV067", timestamp: str = "1634579353"): - signed_data = compute_signed_data(body, nonce, timestamp) - sig = calculate_webhook_signature(signed_data, secret) - return { - "x-sinch-webhook-signature": sig, - "x-sinch-webhook-signature-nonce": nonce, - "x-sinch-webhook-signature-timestamp": timestamp, - } +VALID_SIGNATURE_HEADERS = { + "x-sinch-webhook-signature": "Yc+3R1pIS78xLASybulhs8BsSo9BPB3Pr92QCUoczfk=", + "x-sinch-webhook-signature-nonce": "01FJA8B4A7BM43YGWSG9GBV067", + "x-sinch-webhook-signature-timestamp": "1634579353", +} -def test_validate_signature_valid_expects_true(conversation_webhooks, sample_body, webhook_secret): - headers = _make_signed_headers(sample_body, webhook_secret) - assert conversation_webhooks.validate_signature(sample_body, headers) is True +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_signature_missing_headers_expects_false(conversation_webhooks, sample_body): - assert conversation_webhooks.validate_signature(sample_body, {}) is False +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_signature_invalid_signature_expects_false(conversation_webhooks, sample_body): +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_signature(sample_body, headers) is False + assert conversation_webhooks.validate_authentication_header(headers, sample_body) is False def test_parse_event_message_delivery_expects_message_delivery_receipt_event(conversation_webhooks): @@ -124,11 +117,11 @@ def test_parse_event_message_submit_expects_message_submit_event(conversation_we def test_parse_event_json_string_expects_parsed(conversation_webhooks): - payload_str = '{"app_id":"app1","message_delivery_report":{"status":"SUCCESS"}}' + 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 == "SUCCESS" + assert event.message_delivery_report.status == "DELIVERED" def test_parse_event_invalid_json_expects_value_error(conversation_webhooks):